Compare commits

..

25 Commits

Author SHA1 Message Date
louiscklaw
a4d0d8b746 feat: clarify FAQ guidance on updating git staged files, adding reference to sibling files in same directory 2025-06-17 20:15:57 +08:00
louiscklaw
f8919b8c84 feat: update FAQ with guidance on updating git staged files, specifying to preserve format and detail level while avoiding unintended code changes 2025-06-17 20:12:39 +08:00
louiscklaw
583e31fd4d feat: refactor party event action types and functions, update imports and naming conventions to use PartyEvent naming scheme, add createPartyUser function to party-user actions 2025-06-17 20:06:47 +08:00
louiscklaw
ae7f005236 feat: enhance party user schema with address fields, update frontend form and API calls to use PartyUser naming convention 2025-06-17 19:59:09 +08:00
louiscklaw
834f9360ba feat: update import path for UserCreateView to use party-user view module 2025-06-17 19:58:56 +08:00
louiscklaw
1cb018d4d5 "feat: simplify db push script by removing restart loop and directly executing db:push and seed" 2025-06-17 19:58:45 +08:00
louiscklaw
448825545e "feat: refactor party user data fetching to use new endpoint URL builder and implement 2025-06-17 19:18:21 +08:00
louiscklaw
7c7a532381 fix: disable i18n debug mode in production 2025-06-17 19:18:13 +08:00
louiscklaw
eb515dbe68 feat: enhance party user schema with company, status, role and verification fields, update seeding and frontend form 2025-06-17 18:31:20 +08:00
louiscklaw
7a793be610 feat: update imports and component structure in party-user list view 2025-06-17 18:31:07 +08:00
louiscklaw
1d89134ea2 fix: remove unused tax calculation code in product form 2025-06-17 18:30:30 +08:00
louiscklaw
44d324b40e feat: update frontend references from User to PartyUser for party user management module 2025-06-16 10:20:09 +08:00
louiscklaw
ee2a377bf6 feat: add frontend environment variables template with server URL, API keys and service configurations 2025-06-16 10:20:01 +08:00
louiscklaw
0941ab6dd1 feat: update frontend component names from User to PartyUser for consistency with party-user module 2025-06-16 10:08:43 +08:00
louiscklaw
e93c5dcf62 in the middle of party-user frontend, 2025-06-16 03:03:54 +08:00
louiscklaw
17aaf97722 feat: add REQ0188 frontend party-user CRUD functionality with backend API endpoints and database schema 2025-06-16 02:26:41 +08:00
louiscklaw
47660be0cd docs: update FAQ with question about introducing new format or ideas 2025-06-16 02:24:47 +08:00
louiscklaw
cf5cfb8d63 "feat: add mobile app support with Dockerfile and dev/build scripts, update all projects to use psmisc for process management" 2025-06-16 01:46:15 +08:00
louiscklaw
a686dd55dd feat: update REQ0187 requirements with TODO items for nav bar cleanup and status change implementation 2025-06-16 00:36:37 +08:00
louiscklaw
8d52be9b96 "fix: update party order details route variable naming and test case ID" 2025-06-16 00:35:17 +08:00
louiscklaw
db16b2d5dd party-order delete ok, 2025-06-16 00:07:22 +08:00
louiscklaw
7370316ea0 party-order list ok, 2025-06-16 00:01:36 +08:00
louiscklaw
77f7211317 feat: add REQ0189 frontend party-order list and details pages with mock data and API integration 2025-06-15 23:08:14 +08:00
louiscklaw
dfc9873815 feat: add REQ0187 frontend party-order CRUD functionality and backend API endpoints 2025-06-15 22:27:44 +08:00
louiscklaw
53b112e488 "docs: update FAQ with questions about modifying .tsx.draft files and replacement starting point" 2025-06-15 22:27:06 +08:00
116 changed files with 6706 additions and 40 deletions

View File

@@ -0,0 +1,23 @@
---
tags: frontend, party-order
---
# REQ0185 frontend party-order
frontend page to handle party-order (CRUD)
edit page T.B.A.
## TODO
- remove detail in left nav bar
- implement `changeStatus`
## sources
T.B.A.
## branch
develop/frontend/party-order/trunk
develop/requirements/REQ0187

View 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

View File

@@ -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

View File

@@ -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 {
@@ -1230,3 +1234,60 @@ model AccessLog {
@@index([timestamp])
@@index([userId])
}
model PartyOrderItem {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
//
taxes Float
status String
shipping Float
discount Float
subtotal Float
orderNumber String
totalAmount Float
totalQuantity Float
history Json
payment Json
customer Json
delivery Json
items Json[]
shippingAddress Json
// OrderProductItem OrderProductItem[]
// OrderHistory OrderHistory[]
// OrderDelivery OrderDelivery[]
// OrderCustomer OrderCustomer[]
// 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("")
}

View File

@@ -31,6 +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';
@@ -59,6 +62,9 @@ import { userMetaSeed } from './seeds/userMeta';
//
await appLogSeed;
await accessLogSeed;
//
await partyOrderItemSeed;
await partyUserSeed;
// await Blog;
// await Mail;

View File

@@ -0,0 +1,94 @@
import { PrismaClient } from '@prisma/client';
import { _mock } from './_mock';
const prisma = new PrismaClient();
const ITEMS = Array.from({ length: 3 }, (_, index) => ({
id: _mock.id(index),
sku: `16H9UR${index}`,
quantity: index + 1,
name: _mock.productName(index),
coverUrl: _mock.image.product(index),
price: _mock.number.price(index),
}));
async function partyOrderItem() {
await prisma.partyOrderItem.deleteMany({});
for (let index = 1; index < 20 + 1; index++) {
const shipping = 10;
const discount = 10;
const taxes = 10;
const items = (index % 2 && ITEMS.slice(0, 1)) || (index % 3 && ITEMS.slice(1, 3)) || ITEMS;
const totalQuantity = items.reduce((accumulator, item) => accumulator + item.quantity, 0);
const subtotal = items.reduce((accumulator, item) => accumulator + item.price * item.quantity, 0);
const totalAmount = subtotal - shipping - discount + taxes;
const customer = {
id: _mock.id(index),
name: _mock.fullName(index),
email: _mock.email(index),
avatarUrl: _mock.image.avatar(index),
ipAddress: '192.158.1.38',
};
const delivery = { shipBy: 'DHL', speedy: 'Standard', trackingNumber: 'SPX037739199373' };
const history = {
orderTime: _mock.time(1),
paymentTime: _mock.time(2),
deliveryTime: _mock.time(3),
completionTime: _mock.time(4),
timeline: [
{ title: 'Delivery successful', time: _mock.time(1) },
{ title: 'Transporting to [2]', time: _mock.time(2) },
{ title: 'Transporting to [1]', time: _mock.time(3) },
{ title: 'The shipping unit has picked up the goods', time: _mock.time(4) },
{ title: 'Order has been created', time: _mock.time(5) },
],
};
const temp = await prisma.partyOrderItem.upsert({
where: { id: index.toString() },
update: {},
create: {
id: _mock.id(index),
orderNumber: `#601${index}`,
taxes,
items,
history,
subtotal: items.reduce((accumulator, item) => accumulator + item.price * item.quantity, 0),
shipping,
discount,
customer,
delivery,
totalAmount,
totalQuantity,
shippingAddress: {
fullAddress: '19034 Verna Unions Apt. 164 - Honolulu, RI / 87535',
phoneNumber: '365-374-4961',
},
payment: {
//
cardType: 'mastercard',
cardNumber: '4111 1111 1111 1111',
},
status: (index % 2 && 'completed') || (index % 3 && 'pending') || (index % 4 && 'cancelled') || 'refunded',
},
});
}
console.log('seed partyOrderItemSeed done');
}
const partyOrderItemSeed = partyOrderItem()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
export { partyOrderItemSeed };

View 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 };

View File

@@ -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

View File

@@ -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"

View File

@@ -2,6 +2,8 @@
set -x
killall node
killall yarn
rm -rf ./**/*Zone.Identifier
# yarn db:push

View File

@@ -0,0 +1,14 @@
Hi,
i copied from
`03_source/cms_backend/src/app/api/party-event`
to
`03_source/cms_backend/src/app/api/party-order`
with knowledge in `schema.prisma` file, and take a look into the sibling files in the same directory.
i want you to update `03_source/cms_backend/src/app/api/party-order` content to handle `party-order` (the purchase order of the party)
`party-order.service.ts` is already prepared and you can use it
please maintain same format and level of detail when you edit.
thanks.

View File

@@ -0,0 +1,35 @@
<!-- AI: please maintain same format and level of detail when edit this file -->
# GUIDELINE
- Party Event API endpoint for managing party events
- Handles CRUD operations for party events
- Follows single file for single db table/collection pattern
## `route.ts`
Handles HTTP methods:
- `GET` - Retrieve party events
- `POST` - Create new party event
- `PUT` - Update existing party event
- `DELETE` - Remove party event
## `test.http`
Contains test requests for:
- Listing all party events
- Creating new party event
- Updating existing party event
- Deleting party event
## `../../services/party-event.service.ts`
Party Event CRUD operations:
`listPartyEvents` - List all party events with optional filters
`getPartyEvent` - Get single party event by ID
`createNewPartyEvent` - Create new party event record
`updatePartyEvent` - Update existing party event by ID
`deletePartyEvent` - Delete party event by ID

View File

@@ -0,0 +1,35 @@
<!-- AI: please maintain same format and level of detail when edit this file -->
# GUIDELINE
- Party Order API endpoint for managing party orders
- Handles CRUD operations for party orders
- Follows single file for single db table/collection pattern
## `route.ts`
Handles HTTP methods:
- `GET` - Retrieve party orders
- `POST` - Create new party order
- `PUT` - Update existing party order
- `DELETE` - Remove party order
## `test.http`
Contains test requests for:
- Listing all party orders
- Creating new party order
- Updating existing party order
- Deleting party order
## `../../services/party-order.service.ts`
Party Order CRUD operations:
`listPartyOrders` - List all party orders with optional filters
`getPartyOrder` - Get single party order by ID
`createNewPartyOrder` - Create new party order record
`updatePartyOrder` - Update existing party order by ID
`deletePartyOrder` - Delete party order by ID

View File

@@ -0,0 +1,40 @@
// src/app/api/party-order/create/route.ts
//
// PURPOSE:
// Create new party order in db
//
// RULES:
// T.B.A.
import type { NextRequest } from 'next/server';
import { STATUS, response, handleError } from 'src/utils/response';
import { isDev } from 'src/constants';
import { createOrder } from 'src/app/services/party-order.service';
// ----------------------------------------------------------------------
/** **************************************
* POST - Create PartyOrder
*************************************** */
export async function POST(req: NextRequest) {
const { partyOrderData } = await req.json();
try {
if (isDev) {
console.log({ partyOrderData });
}
const created = await createOrder(partyOrderData);
if (isDev) {
console.log('Order created successfully');
}
return response(created, STATUS.OK);
} catch (error) {
console.error('Error creating order:', { partyOrderData });
return handleError('PartyOrder - Create', error);
}
}

View File

@@ -0,0 +1,31 @@
###
POST http://localhost:7272/api/party-order/create
Content-Type: application/json
{
"partyOrderData": {
"taxes": 15.00,
"status": "pending",
"shipping": 10.00,
"discount": 20.00,
"subtotal": 290.00,
"orderNumber": "ORD_20231115001",
"totalAmount": 300.00,
"totalQuantity": 2.0,
"history": "{\"actions\":[{\"timestamp\":\"2023-11-15T10:00:00Z\",\"action\":\"order_created\"}]}",
"payment": "{\"method\":\"credit_card\",\"status\":\"unpaid\",\"transaction_id\":\"txn_123456\"}",
"customer": "{\"name\":\"John Doe\",\"email\":\"john.doe@example.com\",\"phone\":\"+1234567890\"}",
"delivery": "{\"method\":\"courier\",\"estimated_delivery\":\"2023-11-18\"}",
"items": [
{
"id": "ticket_001",
"name": "General Admission",
"quantity": 2,
"price": 150.00
}
],
"shippingAddress": "{\"street\":\"123 Main St\",\"city\":\"New York\",\"state\":\"NY\",\"zip\":\"10001\",\"country\":\"USA\"}"
}
}

View File

@@ -0,0 +1,22 @@
import type { NextRequest } from 'next/server';
import { STATUS, response, handleError } from 'src/utils/response';
import { deleteOrder } from 'src/app/services/party-order.service';
/** **************************************
* PATCH - Delete PartyOrder
*************************************** */
export async function PATCH(req: NextRequest) {
try {
const { partyOrderId } = await req.json();
if (!partyOrderId) throw new Error('orderId cannot be null');
await deleteOrder(partyOrderId);
return response({ partyOrderId }, STATUS.OK);
} catch (error) {
return handleError('PartyOrder - Delete', error);
}
}

View File

@@ -0,0 +1,8 @@
###
PATCH http://localhost:7272/api/party-order/delete
Content-Type: application/json
{
"partyOrderId": "e99f09a7-dd88-49d5-b1c8-1daf80c2d7b02"
}

View File

@@ -0,0 +1,56 @@
// src/app/api/party-order/details/route.ts
//
// PURPOSE:
// Get party order from db by id
//
// RULES:
// Do not modify the format and level of details thanks.
import type { NextRequest } from 'next/server';
import { logger } from 'src/utils/logger';
import { STATUS, response, handleError } from 'src/utils/response';
import { L_INFO, L_ERROR } from 'src/constants';
import { getOrder } from 'src/app/services/party-order.service';
import { createAppLog } from 'src/app/services/app-log.service';
import { flattenNextjsRequest } from '../../auth/sign-in/flattenNextjsRequest';
// ----------------------------------------------------------------------
/**
**************************************
* GET PartyOrder detail
***************************************
*/
export async function GET(req: NextRequest) {
const debug = { 'req.headers': flattenNextjsRequest(req) };
try {
const { searchParams } = req.nextUrl;
// RULES: partyOrderId must exist
const partyOrderId = searchParams.get('partyOrderId');
if (!partyOrderId) {
return response({ message: 'Order ID is required!' }, STATUS.BAD_REQUEST);
}
// NOTE: partyOrderId confirmed exist, run below
const partyOrder = await getOrder(partyOrderId);
if (!partyOrder) {
return response({ message: 'Order not found!' }, STATUS.NOT_FOUND);
}
logger('[PartyOrder] details', partyOrder.id);
createAppLog(L_INFO, 'Get order detail OK', debug);
return response({ partyOrder }, STATUS.OK);
} catch (error) {
createAppLog(L_ERROR, 'order detail error', debug);
return handleError('PartyOrder - Get details', error);
}
}

View File

@@ -0,0 +1,9 @@
###
# Get details for a specific party order
GET http://localhost:7272/api/party-order/details?partyOrderId=e99f09a7-dd88-49d5-b1c8-1daf80c2d7b13
###
# Alternative format with different ID
GET http://localhost:7272/api/party-order/details?id=ord_987654321

View File

@@ -0,0 +1,16 @@
import type { NextRequest, NextResponse } from 'next/server';
import { STATUS, response, handleError } from 'src/utils/response';
/**
***************************************
* GET - helloworld
***************************************
*/
export async function GET(req: NextRequest, res: NextResponse) {
try {
return response({ helloworld: 'party-order' }, STATUS.OK);
} catch (error) {
return handleError('Helloworld - Get all', error);
}
}

View File

@@ -0,0 +1,2 @@
###
GET http://localhost:7272/api/party-order/helloworld

View File

@@ -0,0 +1,38 @@
// src/app/api/party-order/list/route.ts
//
// PURPOSE:
// List all party orders from db
//
// RULES:
// T.B.A.
import type { NextRequest } from 'next/server';
import { STATUS, response, handleError } from 'src/utils/response';
import { L_INFO, L_ERROR } from 'src/constants';
import { createAppLog } from 'src/app/services/app-log.service';
import { listPartyOrders } from 'src/app/services/party-order.service';
import { flattenNextjsRequest } from '../../auth/sign-in/flattenNextjsRequest';
// ----------------------------------------------------------------------
/** **************************************
* GET - PartyOrders list
*************************************** */
export async function GET(req: NextRequest) {
const debug = { 'req.headers': flattenNextjsRequest(req) };
try {
const partyOrders = await listPartyOrders();
createAppLog(L_INFO, 'party-order list ok', {});
return response({ partyOrders }, STATUS.OK);
} catch (error) {
createAppLog(L_ERROR, 'party-order list error', debug);
return handleError('PartyOrder - Get list', error);
}
}

View File

@@ -0,0 +1,19 @@
###
# Basic list all orders
GET http://localhost:7272/api/party-order/list
###
# List orders by status
GET http://localhost:7272/api/party-order/list?status=completed
###
# List orders by user
GET http://localhost:7272/api/party-order/list?userId=usr_987654321
###
# List orders by payment status
GET http://localhost:7272/api/party-order/list?paymentStatus=paid

View File

@@ -0,0 +1,75 @@
import type { NextRequest, NextResponse } from 'next/server';
import { STATUS, response, handleError } from 'src/utils/response';
import { listPartyOrders, deleteOrder, updateOrder, createOrder } from 'src/app/services/party-order.service';
/**
**************************************
* GET - PartyOrder
***************************************
*/
export async function GET(req: NextRequest, res: NextResponse) {
try {
const orders = await listPartyOrders();
return response(orders, STATUS.OK);
} catch (error) {
return handleError('PartyOrder - Get list', error);
}
}
/**
***************************************
* POST - Create PartyOrder
***************************************
*/
export async function POST(req: NextRequest) {
const OPERATION = 'PartyOrder - Create';
const { data } = await req.json();
try {
const order = await createOrder(data);
return response(OPERATION, STATUS.OK);
} catch (error) {
return handleError(OPERATION, error);
}
}
/**
***************************************
* PUT - Update PartyOrder
***************************************
*/
export async function PUT(req: NextRequest) {
const { searchParams } = req.nextUrl;
const orderId = searchParams.get('orderId');
const { data } = await req.json();
try {
if (!orderId) throw new Error('orderId cannot be null');
const result = await updateOrder(orderId, data);
return response(result, STATUS.OK);
} catch (error) {
return handleError('PartyOrder - Update', error);
}
}
/**
***************************************
* DELETE - Delete PartyOrder
***************************************
*/
export async function DELETE(req: NextRequest) {
const { searchParams } = req.nextUrl;
const orderId = searchParams.get('orderId');
try {
if (!orderId) throw new Error('orderId cannot be null');
await deleteOrder(orderId);
return response({ success: true }, STATUS.OK);
} catch (error) {
return handleError('PartyOrder - Delete', error);
}
}

View 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 type { IOrderItem } from '../update/route';
// import { searchEvents } from 'src/app/services/party-event.service';
// ----------------------------------------------------------------------
/** **************************************
* GET - Search PartyEvents
*************************************** */
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 results: IOrderItem[] = [];
// TODO: search party event not implemented
console.log('search party event not implemented');
// const results = await searchEvents(query);
logger('[PartyEvent] search-results', results?.length);
return response({ results }, STATUS.OK);
} catch (error) {
return handleError('PartyEvent - Get search', error);
}
}

View File

@@ -0,0 +1,24 @@
###
# Search party events by title
GET http://localhost:7272/api/party-event/search?query=Music
###
# Search party events by location
GET http://localhost:7272/api/party-event/search?query=Central+Park
###
# Search party events by tag
GET http://localhost:7272/api/party-event/search?query=Festival
###
# Combined search with multiple parameters
GET http://localhost:7272/api/party-event/search?query=Summer&location=Hong+Kong&category=Music
###
# No results expected
GET http://localhost:7272/api/party-event/search?query=zzzzzz

View File

@@ -0,0 +1,51 @@
// src/app/api/party-order/update/route.ts
//
// PURPOSE:
// Update party order in db by id
//
// RULES:
// T.B.A.
import type { NextRequest } from 'next/server';
import { STATUS, response, handleError } from 'src/utils/response';
import { updateOrder } from 'src/app/services/party-order.service';
// ----------------------------------------------------------------------
/** **************************************
* PUT - Update PartyOrder
*************************************** */
export async function PUT(req: NextRequest) {
const { orderData } = await req.json();
const { id } = orderData;
if (!id) return response({ message: 'id not found' }, STATUS.ERROR);
try {
const result = await updateOrder(id, orderData);
return response({ result }, STATUS.OK);
} catch (error) {
console.error('Error updating order:', { orderData });
return handleError('PartyOrder - Update', error);
}
}
export type IOrderItem = {
id: string;
eventId: string;
userId: string;
status: string;
paymentStatus: string;
totalAmount: number;
items: {
id: string;
name: string;
quantity: number;
price: number;
}[];
createdAt: Date;
updatedAt: Date;
};

View File

@@ -0,0 +1,51 @@
###
PUT http://localhost:7272/api/party-order/update
Content-Type: application/json
{
"orderData": {
"id":"e99f09a7-dd88-49d5-b1c8-1daf80c2d7b02",
"taxes": 10.50,
"status": "completed",
"shipping": 5.00,
"discount": 20.00,
"subtotal": 290.00,
"orderNumber": "ORD-2023-001",
"totalAmount": 300.00,
"totalQuantity": 2,
"history": {
"payment": "2023-01-01",
"status_changes": [
"pending",
"paid"
]
},
"payment": {
"method": "credit_card",
"card_last4": "4321"
},
"customer": {
"name": "John Doe",
"email": "john@example.com"
},
"delivery": {
"method": "express",
"tracking_number": "TRK123456"
},
"items": [
{
"id": "ticket_001",
"name": "General Admission",
"quantity": 2,
"price": 150.00,
"status": "used"
}
],
"shippingAddress": {
"street": "123 Main St",
"city": "New York",
"zip": "10001"
}
}
}

View 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

View 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);
}
}

View File

@@ -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": ""
}
}

View 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);
}
}

View File

@@ -0,0 +1,8 @@
###
PATCH http://localhost:7272/api/party-user/delete
Content-Type: application/json
{
"partyUserId": "cmbxz8t2b000oiigxib3o4jla"
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,4 @@
###
GET http://localhost:7272/api/party-user/details?partyUserId=cmbxziat6000b715hmhrnwlx2

View File

@@ -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);
}
}

View File

@@ -0,0 +1,3 @@
###
GET http://localhost:7272/api/party-user/helloworld

View 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);
}
}

View File

@@ -0,0 +1,2 @@
###
GET http://localhost:7272/api/party-user/list

View 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);
}
}

View 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[];
};

View File

@@ -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"
}
}

View File

@@ -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';
// ----------------------------------------------------------------------

View 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

View File

@@ -1,7 +1,13 @@
with knowledge in schema.prisma file,
please refer the below helloworld example `helloworld.service.ts`
and create `user.service.ts` to cover user record
Hi,
thanks
i copied from
`03_source/cms_backend/src/app/services/user.service.ts`
to
`03_source/cms_backend/src/app/services/party-user.service.ts`
`/home/logic/_wsl_workspace/001_github_ws/HKSingleParty-ws/HKSingleParty/03_source/cms_backend/src/app/services/helloworld.service.ts`
with knowledge in `schema.prisma` file, and reference to the sibling files in same folder
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.

View File

@@ -0,0 +1,66 @@
// src/app/services/party-order.service.ts
//
// PURPOSE:
// - Service for handling PartyOrderItem Record
//
import type { PartyOrderItem } from '@prisma/client';
import prisma from '../lib/prisma';
type CreateOrder = {
status: string;
taxes: number;
shipping: number;
discount: number;
items: any[];
customer: any;
payment: any;
delivery: any;
shippingAddress: any;
billingAddress: any;
};
type UpdateOrder = {
status?: string;
taxes?: number;
shipping?: number;
discount?: number;
items?: any[];
customer?: any;
payment?: any;
delivery?: any;
shippingAddress?: any;
billingAddress?: any;
};
async function listPartyOrders(): Promise<PartyOrderItem[]> {
return prisma.partyOrderItem.findMany();
}
async function getPartyOrder(partyOrderId: string): Promise<PartyOrderItem | null> {
return prisma.partyOrderItem.findUnique({
where: { id: partyOrderId },
});
}
async function createOrder(orderData: any) {
return prisma.partyOrderItem.create({
data: orderData,
});
}
async function updateOrder(orderId: string, updateData: UpdateOrder) {
return prisma.partyOrderItem.update({
where: { id: orderId },
data: updateData,
});
}
async function deleteOrder(orderId: string) {
return prisma.partyOrderItem.delete({
where: { id: orderId },
});
}
export { getPartyOrder as getOrder, createOrder, updateOrder, deleteOrder, listPartyOrders, type CreateOrder, type UpdateOrder };

View 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,
//
};

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -10,6 +10,9 @@ while true; do
yarn dev --force --clearScreen
killall node
killall yarn
echo "restarting..."
sleep 1
done

View File

@@ -2,6 +2,8 @@
set -x
killall node
killall yarn
rm -rf ./**/*Zone.Identifier
set -ex

View File

@@ -1,14 +1,14 @@
export const PRODUCT_GENDER_OPTIONS = [
export const PARTY_EVENT_GENDER_OPTIONS = [
{ label: 'Men', value: 'Men' },
{ label: 'Women', value: 'Women' },
{ label: 'Kids', value: 'Kids' },
];
export const PRODUCT_CATEGORY_OPTIONS = ['Shose', 'Apparel', 'Accessories'];
export const PARTY_EVENT_CATEGORY_OPTIONS = ['Shose', 'Apparel', 'Accessories'];
export const PRODUCT_RATING_OPTIONS = ['up4Star', 'up3Star', 'up2Star', 'up1Star'];
export const PARTY_EVENT_RATING_OPTIONS = ['up4Star', 'up3Star', 'up2Star', 'up1Star'];
export const PRODUCT_COLOR_OPTIONS = [
export const PARTY_EVENT_COLOR_OPTIONS = [
'#FF4842',
'#1890FF',
'#FFC0CB',
@@ -19,7 +19,7 @@ export const PRODUCT_COLOR_OPTIONS = [
'#FFFFFF',
];
export const PRODUCT_COLOR_NAME_OPTIONS = [
export const PARTY_EVENT_COLOR_NAME_OPTIONS = [
{ value: '#FF4842', label: 'Red' },
{ value: '#1890FF', label: 'Blue' },
{ value: '#FFC0CB', label: 'Pink' },
@@ -30,7 +30,7 @@ export const PRODUCT_COLOR_NAME_OPTIONS = [
{ value: '#FFFFFF', label: 'White' },
];
export const PRODUCT_SIZE_OPTIONS = [
export const PARTY_EVENT_SIZE_OPTIONS = [
{ value: '7', label: '7' },
{ value: '8', label: '8' },
{ value: '8.5', label: '8.5' },
@@ -44,26 +44,26 @@ export const PRODUCT_SIZE_OPTIONS = [
{ value: '13', label: '13' },
];
export const PRODUCT_STOCK_OPTIONS = [
export const PARTY_EVENT_STOCK_OPTIONS = [
{ value: 'in stock', label: 'In stock' },
{ value: 'low stock', label: 'Low stock' },
{ value: 'out of stock', label: 'Out of stock' },
];
// not used due to i18n
export const PRODUCT_PUBLISH_OPTIONS = [
export const PARTY_EVENT_PUBLISH_OPTIONS = [
{ value: 'published', label: 'Published' },
{ value: 'draft', label: 'Draft' },
];
export const PRODUCT_SORT_OPTIONS = [
export const PARTY_EVENT_SORT_OPTIONS = [
{ value: 'featured', label: 'Featured' },
{ value: 'newest', label: 'Newest' },
{ value: 'priceDesc', label: 'Price: High - Low' },
{ value: 'priceAsc', label: 'Price: Low - High' },
];
export const PRODUCT_CATEGORY_GROUP_OPTIONS = [
export const PARTY_EVENT_CATEGORY_GROUP_OPTIONS = [
{ group: 'Clothing', classify: ['Shirts', 'T-shirts', 'Jeans', 'Leather', 'Accessories'] },
{ group: 'Tailored', classify: ['Suits', 'Blazers', 'Trousers', 'Waistcoats', 'Apparel'] },
{ group: 'Accessories', classify: ['Shoes', 'Backpacks and bags', 'Bracelets', 'Face masks'] },

View File

@@ -0,0 +1,89 @@
import { _mock } from './_mock';
// ----------------------------------------------------------------------
export const PARTY_ORDER_STATUS_OPTIONS = [
{ value: 'pending', label: 'Pending' },
{ value: 'completed', label: 'Completed' },
{ value: 'cancelled', label: 'Cancelled' },
{ value: 'refunded', label: 'Refunded' },
];
const ITEMS = Array.from({ length: 3 }, (_, index) => ({
id: _mock.id(index),
sku: `16H9UR${index}`,
quantity: index + 1,
name: _mock.productName(index),
coverUrl: _mock.image.product(index),
price: _mock.number.price(index),
}));
export const _party_orders = Array.from({ length: 20 }, (_, index) => {
const shipping = 10;
const discount = 10;
const taxes = 10;
const items = (index % 2 && ITEMS.slice(0, 1)) || (index % 3 && ITEMS.slice(1, 3)) || ITEMS;
const totalQuantity = items.reduce((accumulator, item) => accumulator + item.quantity, 0);
const subtotal = items.reduce((accumulator, item) => accumulator + item.price * item.quantity, 0);
const totalAmount = subtotal - shipping - discount + taxes;
const customer = {
id: _mock.id(index),
name: _mock.fullName(index),
email: _mock.email(index),
avatarUrl: _mock.image.avatar(index),
ipAddress: '192.158.1.38',
};
const delivery = { shipBy: 'DHL', speedy: 'Standard', trackingNumber: 'SPX037739199373' };
const history = {
orderTime: _mock.time(1),
paymentTime: _mock.time(2),
deliveryTime: _mock.time(3),
completionTime: _mock.time(4),
timeline: [
{ title: 'Delivery successful', time: _mock.time(1) },
{ title: 'Transporting to [2]', time: _mock.time(2) },
{ title: 'Transporting to [1]', time: _mock.time(3) },
{ title: 'The shipping unit has picked up the goods', time: _mock.time(4) },
{ title: 'Order has been created', time: _mock.time(5) },
],
};
return {
id: _mock.id(index),
orderNumber: `#601${index}`,
createdAt: _mock.time(index),
taxes,
items,
history,
subtotal,
shipping,
discount,
customer,
delivery,
totalAmount,
totalQuantity,
shippingAddress: {
fullAddress: '19034 Verna Unions Apt. 164 - Honolulu, RI / 87535',
phoneNumber: '365-374-4961',
},
payment: {
//
cardType: 'mastercard',
cardNumber: '**** **** **** 5678',
},
status:
(index % 2 && 'completed') ||
(index % 3 && 'pending') ||
(index % 4 && 'cancelled') ||
'refunded',
};
});

View File

@@ -23,3 +23,7 @@ export * from './_product';
export * from './_overview';
export * from './_calendar';
export * from './_party-event';
export * from './_party-order';

View File

@@ -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) => {

View File

@@ -0,0 +1,208 @@
// src/actions/party-order.ts
//
import { useMemo } from 'react';
import axiosInstance, { endpoints, fetcher } from 'src/lib/axios';
import type { IPartyOrderItem } from 'src/types/party-order';
import type { SWRConfiguration } from 'swr';
import useSWR, { mutate } from 'swr';
// ----------------------------------------------------------------------
const swrOptions: SWRConfiguration = {
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
};
// ----------------------------------------------------------------------
type PartyOrdersData = {
partyOrders: IPartyOrderItem[];
};
export function useGetPartyOrders() {
const url = endpoints.partyOrder.list;
const { data, isLoading, error, isValidating } = useSWR<PartyOrdersData>(
url,
fetcher,
swrOptions
);
const memoizedValue = useMemo(
() => ({
partyOrders: data?.partyOrders || [],
partyOrdersLoading: isLoading,
partyOrdersError: error,
partyOrdersValidating: isValidating,
partyOrdersEmpty: !isLoading && !isValidating && !data?.partyOrders.length,
}),
[data?.partyOrders, error, isLoading, isValidating]
);
return memoizedValue;
}
// ----------------------------------------------------------------------
type PartyOrderData = {
partyOrder: IPartyOrderItem;
};
export function useGetPartyOrder(partyOrderId: string) {
const url = partyOrderId ? [endpoints.partyOrder.details, { params: { partyOrderId } }] : '';
const { data, isLoading, error, isValidating } = useSWR<PartyOrderData>(url, fetcher, swrOptions);
const memoizedValue = useMemo(
() => ({
partyOrder: data?.partyOrder,
partyOrderLoading: isLoading,
partyOrderError: error,
partyOrderValidating: isValidating,
}),
[data?.partyOrder, error, isLoading, isValidating]
);
return memoizedValue;
}
// ----------------------------------------------------------------------
type SearchResultsData = {
results: IPartyOrderItem[];
};
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;
}
// ----------------------------------------------------------------------
export async function createPartyOrder(partyOrderData: IPartyOrderItem) {
/**
* Work on server
*/
const data = { partyOrderData };
const {
data: { id },
} = await axiosInstance.post(endpoints.partyOrder.create, data);
/**
* Work in local
*/
mutate(
endpoints.partyOrder.list,
(currentData: any) => {
const currentPartyOrders: IPartyOrderItem[] = currentData?.partyOrders;
const partyOrders = [...currentPartyOrders, { ...partyOrderData, id }];
return { ...currentData, partyOrders };
},
false
);
}
// ----------------------------------------------------------------------
export async function updatePartyOrder(partyOrderData: Partial<IPartyOrderItem>) {
/**
* Work on server
*/
const data = { partyOrderData };
await axiosInstance.put(endpoints.partyOrder.update, data);
/**
* Work in local
*/
mutate(
endpoints.partyOrder.list,
(currentData: any) => {
const currentPartyOrders: IPartyOrderItem[] = currentData?.partyOrders;
const partyOrders = currentPartyOrders.map((partyOrder) =>
partyOrder.id === partyOrderData.id ? { ...partyOrder, ...partyOrderData } : partyOrder
);
return { ...currentData, partyOrders };
},
false
);
}
// ----------------------------------------------------------------------
export async function deletePartyOrder(partyOrderId: string) {
/**
* Work on server
*/
const data = { partyOrderId };
await axiosInstance.patch(endpoints.partyOrder.delete, data);
/**
* Work in local
*/
mutate(
endpoints.partyOrder.list,
(currentData: any) => {
const currentProducts: IPartyOrderItem[] = currentData?.partyOrders;
const partyOrders = currentProducts.filter((partyOrder) => partyOrder.id !== partyOrderId);
return { ...currentData, partyOrders };
},
false
);
}
// ----------------------------------------------------------------------
// TODO: implement partyOrder changeStatus with url below
// const url = endpoints.order.changeStatus(orderId);
export async function changeStatus(partyOrderData: any, dummy: any) {
return true;
// /**
// * Work on server
// */
// const data = { partyOrderData };
// await axiosInstance.put(endpoints.partyOrder.update, data);
// /**
// * Work in local
// */
// mutate(
// endpoints.partyOrder.list,
// (currentData: any) => {
// const currentPartyOrders: IPartyOrderItem[] = currentData?.partyOrders;
// const partyOrders = currentPartyOrders.map((partyOrder) =>
// partyOrder.id === partyOrderData.id ? { ...partyOrder, ...partyOrderData } : partyOrder
// );
// return { ...currentData, partyOrders };
// },
// false
// );
}

View 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
);
}

View File

@@ -102,6 +102,28 @@ export const navData: NavSectionProps['data'] = [
{ title: 'Edit', path: paths.dashboard.partyEvent.demo.edit },
],
},
{
title: 'party-order',
path: paths.dashboard.partyOrder.root,
icon: ICONS.order,
children: [
{ title: 'List', path: paths.dashboard.partyOrder.root },
{ 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,

View File

@@ -84,6 +84,9 @@ export const endpoints = {
changeStatus: (invoiceId: string) => `/api/invoice/changeStatus?invoiceId=${invoiceId}`,
search: '/api/invoice/search',
},
//
//
//
partyEvent: {
list: '/api/party-event/list',
details: '/api/party-event/details',
@@ -92,4 +95,26 @@ export const endpoints = {
update: '/api/party-event/update',
delete: '/api/party-event/delete',
},
partyOrder: {
create: '/api/party-order/create',
delete: '/api/party-order/delete',
list: '/api/party-order/list',
profile: '/api/party-order/profile',
update: '/api/party-order/update',
settings: '/api/party-order/settings',
details: '/api/party-order/details',
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}`,
},
};

View File

@@ -22,7 +22,7 @@ i18next
.init({
...i18nOptions(lng),
detection: { caches: ['localStorage'] },
debug: isDev,
debug: false,
});
// ----------------------------------------------------------------------

View File

@@ -0,0 +1,28 @@
// src/pages/dashboard/order/details.tsx
//
import { useGetPartyOrder } from 'src/actions/party-order';
import { CONFIG } from 'src/global-config';
import { useParams } from 'src/routes/hooks';
import { PartyOrderDetailsView } from 'src/sections/party-order/view';
// ----------------------------------------------------------------------
const metadata = { title: `Order details | Dashboard - ${CONFIG.appName}` };
export default function Page() {
const { id = '' } = useParams();
// const currentOrder = _orders.find((order) => order.id === id);
// TODO: error handling
const { partyOrder, partyOrderLoading, partyOrderError } = useGetPartyOrder(id);
if (!partyOrder) return <></>;
return (
<>
<title>{metadata.title}</title>
<PartyOrderDetailsView partyOrder={partyOrder} />
</>
);
}

View File

@@ -0,0 +1,18 @@
// src/pages/dashboard/party-order/list.tsx
//
import { CONFIG } from 'src/global-config';
import { PartyOrderListView } from 'src/sections/party-order/view';
// ----------------------------------------------------------------------
const metadata = { title: `Order list | Dashboard - ${CONFIG.appName}` };
export default function Page() {
return (
<>
<title>{metadata.title}</title>
<PartyOrderListView />
</>
);
}

View File

@@ -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 />
</>
);
}

View File

@@ -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 />
</>
);
}

View File

@@ -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 />
</>
);
}

View File

@@ -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 />
</>
);
}

View File

@@ -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 />
</>
);
}

View 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 />
</>
);
}

View 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} />
</>
);
}

View 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 />
</>
);
}

View 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 />
</>
);
}

View File

@@ -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 />
</>
);
}

View File

@@ -179,6 +179,8 @@ export const paths = {
},
},
//
//
//
partyEvent: {
root: `${ROOTS.DASHBOARD}/party-event`,
new: `${ROOTS.DASHBOARD}/party-event/new`,
@@ -189,5 +191,20 @@ export const paths = {
edit: `${ROOTS.DASHBOARD}/party-event/${MOCK_ID}/edit`,
},
},
partyOrder: {
root: `${ROOTS.DASHBOARD}/party-order`,
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` },
},
},
};

View File

@@ -83,6 +83,17 @@ const PartyEventListPage = lazy(() => import('src/pages/dashboard/party-event/li
const PartyEventCreatePage = lazy(() => import('src/pages/dashboard/party-event/new'));
const PartyEventEditPage = lazy(() => import('src/pages/dashboard/party-event/edit'));
// PartyOrder
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() {
@@ -217,6 +228,36 @@ export const dashboardRoutes: RouteObject[] = [
{ path: ':id/edit', element: <PartyEventEditPage /> },
],
},
{
path: 'party-order',
children: [
{ index: true, element: <PartyOrderListPage /> },
{ path: 'list', element: <PartyOrderListPage /> },
{ 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 /> },
],
},
],
},
],
},
];

View File

@@ -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';

View File

@@ -13,7 +13,7 @@ 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 { _orders, ORDER_STATUS_OPTIONS } from 'src/_mock';
import { _party_orders, ORDER_STATUS_OPTIONS } from 'src/_mock';
import { deleteOrder, useGetOrders } from 'src/actions/order';
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
import { ConfirmDialog } from 'src/components/custom-dialog';

View File

@@ -0,0 +1,59 @@
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CardHeader from '@mui/material/CardHeader';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { Iconify } from 'src/components/iconify';
import type { IOrderCustomer } from 'src/types/party-order';
// ----------------------------------------------------------------------
type Props = {
customer?: IOrderCustomer;
};
export function PartyOrderDetailsCustomer({ customer }: Props) {
return (
<>
<CardHeader
title="Customer info"
action={
<IconButton>
<Iconify icon="solar:pen-bold" />
</IconButton>
}
/>
<Box sx={{ p: 3, display: 'flex' }}>
<Avatar
alt={customer?.name}
src={customer?.avatarUrl}
sx={{ width: 48, height: 48, mr: 2 }}
/>
<Stack spacing={0.5} sx={{ typography: 'body2', alignItems: 'flex-start' }}>
<Typography variant="subtitle2">{customer?.name}</Typography>
<Box sx={{ color: 'text.secondary' }}>{customer?.email}</Box>
<div>
IP address:
<Box component="span" sx={{ color: 'text.secondary', ml: 0.25 }}>
{customer?.ipAddress}
</Box>
</div>
<Button
size="small"
color="error"
startIcon={<Iconify icon="mingcute:add-line" />}
sx={{ mt: 1 }}
>
Add to Blacklist
</Button>
</Stack>
</Box>
</>
);
}

View File

@@ -0,0 +1,57 @@
import Box from '@mui/material/Box';
import CardHeader from '@mui/material/CardHeader';
import IconButton from '@mui/material/IconButton';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import { useTranslation } from 'react-i18next';
import { Iconify } from 'src/components/iconify';
import type { IOrderDelivery } from 'src/types/party-order';
// ----------------------------------------------------------------------
type Props = {
delivery?: IOrderDelivery;
};
export function PartyOrderDetailsDelivery({ delivery }: Props) {
const { t } = useTranslation();
return (
<>
<CardHeader
title={t('Delivery')}
action={
<IconButton>
<Iconify icon="solar:pen-bold" />
</IconButton>
}
/>
<Stack spacing={1.5} sx={{ p: 3, typography: 'body2' }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box component="span" sx={{ color: 'text.secondary', width: 120, flexShrink: 0 }}>
{t('Ship by')}
</Box>
{delivery?.shipBy}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box component="span" sx={{ color: 'text.secondary', width: 120, flexShrink: 0 }}>
{t('Speedy')}
</Box>
{delivery?.speedy}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Box component="span" sx={{ color: 'text.secondary', width: 120, flexShrink: 0 }}>
{t('Tracking No.')}
</Box>
<Link underline="always" color="inherit">
{delivery?.trackingNumber}
</Link>
</Box>
</Stack>
</>
);
}

View File

@@ -0,0 +1,108 @@
// src/sections/order/order-details-history.tsx
import Timeline from '@mui/lab/Timeline';
import TimelineConnector from '@mui/lab/TimelineConnector';
import TimelineContent from '@mui/lab/TimelineContent';
import TimelineDot from '@mui/lab/TimelineDot';
import TimelineItem, { timelineItemClasses } from '@mui/lab/TimelineItem';
import TimelineSeparator from '@mui/lab/TimelineSeparator';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardHeader from '@mui/material/CardHeader';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
import type { IOrderHistory } from 'src/types/party-order';
import { fDateTime } from 'src/utils/format-time';
// ----------------------------------------------------------------------
type Props = {
history?: IOrderHistory;
};
export function PartyOrderDetailsHistory({ history }: Props) {
const { t } = useTranslation();
const renderSummary = () => (
<Paper
variant="outlined"
sx={{
p: 2.5,
gap: 2,
minWidth: 260,
flexShrink: 0,
borderRadius: 2,
display: 'flex',
typography: 'body2',
borderStyle: 'dashed',
flexDirection: 'column',
}}
>
<div>
<Box sx={{ mb: 0.5, color: 'text.disabled' }}>{t('Order time')}</Box>
{fDateTime(history?.orderTime)}
</div>
<div>
<Box sx={{ mb: 0.5, color: 'text.disabled' }}>{t('Payment time')}</Box>
{fDateTime(history?.orderTime)}
</div>
<div>
<Box sx={{ mb: 0.5, color: 'text.disabled' }}>{t('Delivery time for the carrier')}</Box>
{fDateTime(history?.orderTime)}
</div>
<div>
<Box sx={{ mb: 0.5, color: 'text.disabled' }}>{t('Completion time')}</Box>
{fDateTime(history?.orderTime)}
</div>
</Paper>
);
const renderTimeline = () => (
<Timeline
sx={{ p: 0, m: 0, [`& .${timelineItemClasses.root}:before`]: { flex: 0, padding: 0 } }}
>
{history?.timeline.map((item, index) => {
const firstTime = index === 0;
const lastTime = index === history.timeline.length - 1;
return (
<TimelineItem key={item.title}>
<TimelineSeparator>
<TimelineDot color={(firstTime && 'primary') || 'grey'} />
{lastTime ? null : <TimelineConnector />}
</TimelineSeparator>
<TimelineContent>
<Typography variant="subtitle2">{item.title}</Typography>
<Box sx={{ color: 'text.disabled', typography: 'caption', mt: 0.5 }}>
{fDateTime(item.time)}
</Box>
</TimelineContent>
</TimelineItem>
);
})}
</Timeline>
);
return (
<Card>
<CardHeader title="History" />
<Box
sx={{
p: 3,
gap: 3,
display: 'flex',
alignItems: { md: 'flex-start' },
flexDirection: { xs: 'column-reverse', md: 'row' },
}}
>
{renderTimeline()}
{renderSummary()}
</Box>
</Card>
);
}

View File

@@ -0,0 +1,130 @@
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 CardHeader from '@mui/material/CardHeader';
import IconButton from '@mui/material/IconButton';
import ListItemText from '@mui/material/ListItemText';
import { useTranslation } from 'react-i18next';
import { Iconify } from 'src/components/iconify';
import { Scrollbar } from 'src/components/scrollbar';
import type { IOrderProductItem } from 'src/types/party-order';
import { fCurrency } from 'src/utils/format-number';
// ----------------------------------------------------------------------
type Props = CardProps & {
taxes?: number;
shipping?: number;
discount?: number;
subtotal?: number;
totalAmount?: number;
items?: IOrderProductItem[];
};
export function PartyOrderDetailsItems({
sx,
taxes,
shipping,
discount,
subtotal,
items = [],
totalAmount,
...other
}: Props) {
const { t } = useTranslation();
const renderTotal = () => (
<Box
sx={{
p: 3,
gap: 2,
display: 'flex',
textAlign: 'right',
typography: 'body2',
alignItems: 'flex-end',
flexDirection: 'column',
}}
>
<Box sx={{ display: 'flex' }}>
<Box sx={{ color: 'text.secondary' }}>{t('Subtotal')}</Box>
<Box sx={{ width: 160, typography: 'subtitle2' }}>{fCurrency(subtotal) || '-'}</Box>
</Box>
<Box sx={{ display: 'flex' }}>
<Box sx={{ color: 'text.secondary' }}>{t('Shipping')}</Box>
<Box sx={{ width: 160, ...(shipping && { color: 'error.main' }) }}>
{shipping ? `- ${fCurrency(shipping)}` : '-'}
</Box>
</Box>
<Box sx={{ display: 'flex' }}>
<Box sx={{ color: 'text.secondary' }}>{t('Discount')}</Box>
<Box sx={{ width: 160, ...(discount && { color: 'error.main' }) }}>
{discount ? `- ${fCurrency(discount)}` : '-'}
</Box>
</Box>
<Box sx={{ display: 'flex' }}>
<Box sx={{ color: 'text.secondary' }}>{t('Taxes')}</Box>
<Box sx={{ width: 160 }}>{taxes ? fCurrency(taxes) : '-'}</Box>
</Box>
<Box sx={{ display: 'flex', typography: 'subtitle1' }}>
<div>{t('Total')}</div>
<Box sx={{ width: 160 }}>{fCurrency(totalAmount) || '-'}</Box>
</Box>
</Box>
);
return (
<Card sx={sx} {...other}>
<CardHeader
title={t('Details')}
action={
<IconButton>
<Iconify icon="solar:pen-bold" />
</IconButton>
}
/>
<Scrollbar>
{items.map((item) => (
<Box
key={item.id}
sx={[
(theme) => ({
p: 3,
minWidth: 640,
display: 'flex',
alignItems: 'center',
borderBottom: `dashed 2px ${theme.vars.palette.background.neutral}`,
}),
]}
>
<Avatar src={item.coverUrl} variant="rounded" sx={{ width: 48, height: 48, mr: 2 }} />
<ListItemText
primary={item.name}
secondary={item.sku}
slotProps={{
primary: { sx: { typography: 'body2' } },
secondary: {
sx: { mt: 0.5, color: 'text.disabled' },
},
}}
/>
<Box sx={{ typography: 'body2' }}>x{item.quantity}</Box>
<Box sx={{ width: 110, textAlign: 'right', typography: 'subtitle2' }}>
{fCurrency(item.price)}
</Box>
</Box>
))}
</Scrollbar>
{renderTotal()}
</Card>
);
}

View File

@@ -0,0 +1,39 @@
import Box from '@mui/material/Box';
import CardHeader from '@mui/material/CardHeader';
import IconButton from '@mui/material/IconButton';
import { Iconify } from 'src/components/iconify';
import type { IOrderPayment } from 'src/types/party-order';
// ----------------------------------------------------------------------
type Props = {
payment?: IOrderPayment;
};
export function PartyOrderDetailsPayment({ payment }: Props) {
return (
<>
<CardHeader
title="Payment"
action={
<IconButton>
<Iconify icon="solar:pen-bold" />
</IconButton>
}
/>
<Box
sx={{
p: 3,
gap: 0.5,
display: 'flex',
typography: 'body2',
alignItems: 'center',
justifyContent: 'flex-end',
}}
>
{payment?.cardNumber}
<Iconify icon="payments:mastercard" width={36} height="auto" />
</Box>
</>
);
}

View File

@@ -0,0 +1,44 @@
import Box from '@mui/material/Box';
import CardHeader from '@mui/material/CardHeader';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import { Iconify } from 'src/components/iconify';
import type { IOrderShippingAddress } from 'src/types/party-order';
// ----------------------------------------------------------------------
type Props = {
shippingAddress?: IOrderShippingAddress;
};
export function PartyOrderDetailsShipping({ shippingAddress }: Props) {
return (
<>
<CardHeader
title="Shipping"
action={
<IconButton>
<Iconify icon="solar:pen-bold" />
</IconButton>
}
/>
<Stack spacing={1.5} sx={{ p: 3, typography: 'body2' }}>
<Box sx={{ display: 'flex' }}>
<Box component="span" sx={{ color: 'text.secondary', width: 120, flexShrink: 0 }}>
Address
</Box>
{shippingAddress?.fullAddress}
</Box>
<Box sx={{ display: 'flex' }}>
<Box component="span" sx={{ color: 'text.secondary', width: 120, flexShrink: 0 }}>
Phone number
</Box>
{shippingAddress?.phoneNumber}
</Box>
</Stack>
</>
);
}

View File

@@ -0,0 +1,138 @@
// src/sections/order/order-details-toolbar.tsx
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import MenuItem from '@mui/material/MenuItem';
import MenuList from '@mui/material/MenuList';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { usePopover } from 'minimal-shared/hooks';
import { useTranslation } from 'react-i18next';
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 { IDateValue } from 'src/types/common';
import { fDateTime } from 'src/utils/format-time';
// ----------------------------------------------------------------------
type Props = {
status?: string;
backHref: string;
orderNumber?: string;
createdAt?: IDateValue;
onChangeStatus: (newValue: string) => void;
statusOptions: { value: string; label: string }[];
};
export function PartyOrderDetailsToolbar({
status,
backHref,
createdAt,
orderNumber,
statusOptions,
onChangeStatus,
}: Props) {
const { t } = useTranslation();
const menuActions = usePopover();
const renderMenuActions = () => (
<CustomPopover
open={menuActions.open}
anchorEl={menuActions.anchorEl}
onClose={menuActions.onClose}
slotProps={{ arrow: { placement: 'top-right' } }}
>
<MenuList>
{statusOptions.map((option) => (
<MenuItem
key={option.value}
selected={option.value === status}
onClick={() => {
menuActions.onClose();
onChangeStatus(option.value);
}}
>
{t(option.label)}
</MenuItem>
))}
</MenuList>
</CustomPopover>
);
return (
<>
<Box
sx={{
gap: 3,
display: 'flex',
mb: { xs: 3, md: 5 },
flexDirection: { xs: 'column', md: 'row' },
}}
>
<Box sx={{ gap: 1, display: 'flex', alignItems: 'flex-start' }}>
<IconButton component={RouterLink} href={backHref}>
<Iconify icon="eva:arrow-ios-back-fill" />
</IconButton>
<Stack spacing={0.5}>
<Box sx={{ gap: 1, display: 'flex', alignItems: 'center' }}>
<Typography variant="h4"> Order {orderNumber} </Typography>
<Label
variant="soft"
color={
(status === 'completed' && 'success') ||
(status === 'pending' && 'warning') ||
(status === 'cancelled' && 'error') ||
'default'
}
>
{status}
</Label>
</Box>
<Typography variant="body2" sx={{ color: 'text.disabled' }}>
{fDateTime(createdAt)}
</Typography>
</Stack>
</Box>
<Box
sx={{
gap: 1.5,
flexGrow: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
}}
>
<Button
color="inherit"
variant="outlined"
endIcon={<Iconify icon="eva:arrow-ios-downward-fill" />}
onClick={menuActions.onOpen}
sx={{ textTransform: 'capitalize' }}
>
{status}
</Button>
<Button
color="inherit"
variant="outlined"
startIcon={<Iconify icon="solar:printer-minimalistic-bold" />}
>
{t('Print (not implemented)')}
</Button>
<Button color="inherit" variant="contained" startIcon={<Iconify icon="solar:pen-bold" />}>
{t('Edit')}
</Button>
</Box>
</Box>
{renderMenuActions()}
</>
);
}

View File

@@ -0,0 +1,66 @@
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 { IPartyOrderTableFilters } from 'src/types/party-order';
import { fDateRangeShortLabel } from 'src/utils/format-time';
// ----------------------------------------------------------------------
type Props = FiltersResultProps & {
onResetPage: () => void;
filters: UseSetStateReturn<IPartyOrderTableFilters>;
};
export function PartyOrderTableFiltersResult({ filters, totalResults, onResetPage, 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 handleRemoveDate = useCallback(() => {
onResetPage();
updateFilters({ startDate: null, endDate: null });
}, [onResetPage, updateFilters]);
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="Date:"
isShow={Boolean(currentFilters.startDate && currentFilters.endDate)}
>
<Chip
{...chipProps}
label={fDateRangeShortLabel(currentFilters.startDate, currentFilters.endDate)}
onDelete={handleRemoveDate}
/>
</FiltersBlock>
<FiltersBlock label="Keyword:" isShow={!!currentFilters.name}>
<Chip {...chipProps} label={currentFilters.name} onDelete={handleRemoveKeyword} />
</FiltersBlock>
</FiltersResult>
);
}

View File

@@ -0,0 +1,237 @@
// src/sections/order/view/order-list-view.tsx
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 Collapse from '@mui/material/Collapse';
import IconButton from '@mui/material/IconButton';
import Link from '@mui/material/Link';
import ListItemText from '@mui/material/ListItemText';
import MenuItem from '@mui/material/MenuItem';
import MenuList from '@mui/material/MenuList';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import TableCell from '@mui/material/TableCell';
import TableRow from '@mui/material/TableRow';
import { useBoolean, usePopover } from 'minimal-shared/hooks';
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 { IPartyOrderItem } from 'src/types/party-order';
import { fCurrency } from 'src/utils/format-number';
import { fDate, fTime } from 'src/utils/format-time';
// ----------------------------------------------------------------------
type Props = {
row: IPartyOrderItem;
selected: boolean;
detailsHref: string;
onSelectRow: () => void;
onDeleteRow: () => void;
};
export function PartyOrderTableRow({
row,
selected,
onSelectRow,
onDeleteRow,
detailsHref,
}: Props) {
const confirmDialog = useBoolean();
const menuActions = usePopover();
const collapseRow = useBoolean();
const { t } = useTranslation();
const renderPrimaryRow = () => (
<TableRow hover selected={selected}>
<TableCell padding="checkbox">
<Checkbox
checked={selected}
onClick={onSelectRow}
slotProps={{
input: {
id: `${row.id}-checkbox`,
'aria-label': `${row.id} checkbox`,
},
}}
/>
</TableCell>
<TableCell>
<Link component={RouterLink} href={detailsHref} color="inherit" underline="always">
{row.orderNumber}
</Link>
</TableCell>
<TableCell>
<Box sx={{ gap: 2, display: 'flex', alignItems: 'center' }}>
<Avatar alt={row.customer.name} src={row.customer.avatarUrl} />
<Stack sx={{ typography: 'body2', flex: '1 1 auto', alignItems: 'flex-start' }}>
<Box component="span">{row.customer.name}</Box>
<Box component="span" sx={{ color: 'text.disabled' }}>
{row.customer.email}
</Box>
</Stack>
</Box>
</TableCell>
<TableCell>
<ListItemText
primary={fDate(row.createdAt)}
secondary={fTime(row.createdAt)}
slotProps={{
primary: {
noWrap: true,
sx: { typography: 'body2' },
},
secondary: {
sx: { mt: 0.5, typography: 'caption' },
},
}}
/>
</TableCell>
<TableCell align="center"> {row.totalQuantity} </TableCell>
<TableCell> {fCurrency(row.subtotal)} </TableCell>
<TableCell>
<Label
variant="soft"
color={
(row.status === 'completed' && 'success') ||
(row.status === 'pending' && 'warning') ||
(row.status === 'cancelled' && 'error') ||
'default'
}
>
{row.status}
</Label>
</TableCell>
<TableCell align="right" sx={{ px: 1, whiteSpace: 'nowrap' }}>
<IconButton
color={collapseRow.value ? 'inherit' : 'default'}
onClick={collapseRow.onToggle}
sx={{ ...(collapseRow.value && { bgcolor: 'action.hover' }) }}
>
<Iconify icon="eva:arrow-ios-downward-fill" />
</IconButton>
<IconButton color={menuActions.open ? 'inherit' : 'default'} onClick={menuActions.onOpen}>
<Iconify icon="eva:more-vertical-fill" />
</IconButton>
</TableCell>
</TableRow>
);
const renderSecondaryRow = () => (
<TableRow>
<TableCell sx={{ p: 0, border: 'none' }} colSpan={8}>
<Collapse
in={collapseRow.value}
timeout="auto"
unmountOnExit
sx={{ bgcolor: 'background.neutral' }}
>
<Paper sx={{ m: 1.5 }}>
{row.items.map((item) => (
<Box
key={item.id}
sx={(theme) => ({
display: 'flex',
alignItems: 'center',
p: theme.spacing(1.5, 2, 1.5, 1.5),
'&:not(:last-of-type)': {
borderBottom: `solid 2px ${theme.vars.palette.background.neutral}`,
},
})}
>
<Avatar
src={item.coverUrl}
variant="rounded"
sx={{ width: 48, height: 48, mr: 2 }}
/>
<ListItemText
primary={item.name}
secondary={item.sku}
slotProps={{
primary: {
sx: { typography: 'body2' },
},
secondary: {
sx: { mt: 0.5, color: 'text.disabled' },
},
}}
/>
<div>x{item.quantity} </div>
<Box sx={{ width: 110, textAlign: 'right' }}>{fCurrency(item.price)}</Box>
</Box>
))}
</Paper>
</Collapse>
</TableCell>
</TableRow>
);
const renderMenuActions = () => (
<CustomPopover
open={menuActions.open}
anchorEl={menuActions.anchorEl}
onClose={menuActions.onClose}
slotProps={{ arrow: { placement: 'right-top' } }}
>
<MenuList>
<MenuItem
onClick={() => {
confirmDialog.onTrue();
menuActions.onClose();
}}
sx={{ color: 'error.main' }}
>
<Iconify icon="solar:trash-bin-trash-bold" />
{t('Delete')}
</MenuItem>
<li>
<MenuItem component={RouterLink} href={detailsHref} onClick={() => menuActions.onClose()}>
<Iconify icon="solar:eye-bold" />
{t('View')}
</MenuItem>
</li>
</MenuList>
</CustomPopover>
);
const renderConfrimDialog = () => (
<ConfirmDialog
open={confirmDialog.value}
onClose={confirmDialog.onFalse}
title="Delete"
content="Are you sure want to delete?"
action={
<Button variant="contained" color="error" onClick={onDeleteRow}>
Delete
</Button>
}
/>
);
return (
<>
{renderPrimaryRow()}
{renderSecondaryRow()}
{renderMenuActions()}
{renderConfrimDialog()}
</>
);
}

View File

@@ -0,0 +1,156 @@
import Box from '@mui/material/Box';
import { formHelperTextClasses } from '@mui/material/FormHelperText';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import MenuItem from '@mui/material/MenuItem';
import MenuList from '@mui/material/MenuList';
import TextField from '@mui/material/TextField';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import type { UseSetStateReturn } from 'minimal-shared/hooks';
import { usePopover } from 'minimal-shared/hooks';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { CustomPopover } from 'src/components/custom-popover';
import { Iconify } from 'src/components/iconify';
import type { IDatePickerControl } from 'src/types/common';
import type { IPartyOrderTableFilters } from 'src/types/party-order';
// ----------------------------------------------------------------------
type Props = {
dateError: boolean;
onResetPage: () => void;
filters: UseSetStateReturn<IPartyOrderTableFilters>;
};
export function PartyOrderTableToolbar({ filters, onResetPage, dateError }: Props) {
const { t } = useTranslation();
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 handleFilterStartDate = useCallback(
(newValue: IDatePickerControl) => {
onResetPage();
updateFilters({ startDate: newValue });
},
[onResetPage, updateFilters]
);
const handleFilterEndDate = useCallback(
(newValue: IDatePickerControl) => {
onResetPage();
updateFilters({ endDate: 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" />
{t('Print')}
</MenuItem>
<MenuItem onClick={() => menuActions.onClose()}>
<Iconify icon="solar:import-bold" />
{t('Import')}
</MenuItem>
<MenuItem onClick={() => menuActions.onClose()}>
<Iconify icon="solar:export-bold" />
{t('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' },
}}
>
<DatePicker
label={t('Start date')}
value={currentFilters.startDate}
onChange={handleFilterStartDate}
slotProps={{ textField: { fullWidth: true } }}
sx={{ maxWidth: { md: 200 } }}
/>
<DatePicker
label={t('End date')}
value={currentFilters.endDate}
onChange={handleFilterEndDate}
slotProps={{
textField: {
fullWidth: true,
error: dateError,
helperText: dateError ? 'End date must be later than start date' : null,
},
}}
sx={{
maxWidth: { md: 200 },
[`& .${formHelperTextClasses.root}`]: {
position: { md: 'absolute' },
bottom: { md: -40 },
},
}}
/>
<Box
sx={{
gap: 2,
width: 1,
flexGrow: 1,
display: 'flex',
alignItems: 'center',
}}
>
<TextField
fullWidth
value={currentFilters.name}
onChange={handleFilterName}
placeholder={t('Search customer or order number...')}
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()}
</>
);
}

View File

@@ -0,0 +1,3 @@
export * from './party-order-list-view';
export * from './party-order-details-view';

View File

@@ -0,0 +1,104 @@
// src/sections/order/view/order-details-view.tsx
//
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import Divider from '@mui/material/Divider';
import Grid from '@mui/material/Grid';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { ORDER_STATUS_OPTIONS } from 'src/_mock';
import { changeStatus } from 'src/actions/party-order';
import { DashboardContent } from 'src/layouts/dashboard';
import { useTranslate } from 'src/locales';
import { paths } from 'src/routes/paths';
import type { IPartyOrderItem } from 'src/types/party-order';
import { PartyOrderDetailsCustomer } from '../party-order-details-customer';
import { PartyOrderDetailsDelivery } from '../party-order-details-delivery';
import { PartyOrderDetailsHistory } from '../party-order-details-history';
import { PartyOrderDetailsItems } from '../party-order-details-items';
import { PartyOrderDetailsPayment } from '../party-order-details-payment';
import { PartyOrderDetailsShipping } from '../party-order-details-shipping';
import { PartyOrderDetailsToolbar } from '../party-order-details-toolbar';
// ----------------------------------------------------------------------
type Props = {
partyOrder: IPartyOrderItem;
};
export function PartyOrderDetailsView({ partyOrder }: Props) {
const { t } = useTranslation();
const [status, setStatus] = useState(partyOrder.status);
const handleChangeStatus = useCallback(
async (newValue: string) => {
setStatus(newValue);
// change order status
try {
if (partyOrder?.id) {
await changeStatus(partyOrder.id, newValue);
toast.success('order status updated');
}
} catch (error) {
console.error(error);
toast.warning('error during update order status');
}
},
[partyOrder.id]
);
return (
<DashboardContent>
<PartyOrderDetailsToolbar
status={status}
createdAt={partyOrder?.createdAt}
orderNumber={partyOrder?.orderNumber}
backHref={paths.dashboard.partyOrder.root}
onChangeStatus={handleChangeStatus}
statusOptions={ORDER_STATUS_OPTIONS}
/>
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 8 }}>
<Box
sx={{
//
gap: 3,
display: 'flex',
flexDirection: { xs: 'column-reverse', md: 'column' },
}}
>
<PartyOrderDetailsItems
items={partyOrder?.items}
taxes={partyOrder?.taxes}
shipping={partyOrder?.shipping}
discount={partyOrder?.discount}
subtotal={partyOrder?.subtotal}
totalAmount={partyOrder?.totalAmount}
/>
<PartyOrderDetailsHistory history={partyOrder?.history} />
</Box>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Card>
<PartyOrderDetailsCustomer customer={partyOrder?.customer} />
<Divider sx={{ borderStyle: 'dashed' }} />
<PartyOrderDetailsDelivery delivery={partyOrder?.delivery} />
<Divider sx={{ borderStyle: 'dashed' }} />
<PartyOrderDetailsShipping shippingAddress={partyOrder?.shippingAddress} />
<Divider sx={{ borderStyle: 'dashed' }} />
<PartyOrderDetailsPayment payment={partyOrder?.payment} />
</Card>
</Grid>
</Grid>
</DashboardContent>
);
}

View File

@@ -0,0 +1,359 @@
// src/sections/order/view/order-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 { _party_orders, PARTY_ORDER_STATUS_OPTIONS } from 'src/_mock';
import { deletePartyOrder, useGetPartyOrders } from 'src/actions/party-order';
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 { IPartyOrderItem, IPartyOrderTableFilters } from 'src/types/party-order';
import { fIsAfter, fIsBetween } from 'src/utils/format-time';
import { PartyOrderTableFiltersResult } from '../party-order-table-filters-result';
import { PartyOrderTableRow } from '../party-order-table-row';
import { PartyOrderTableToolbar } from '../party-order-table-toolbar';
// ----------------------------------------------------------------------
const STATUS_OPTIONS = [{ value: 'all', label: 'All' }, ...PARTY_ORDER_STATUS_OPTIONS];
// ----------------------------------------------------------------------
export function PartyOrderListView() {
const { t } = useTranslation();
const TABLE_HEAD: TableHeadCellProps[] = [
{ id: 'orderNumber', label: t('Order'), width: 88 },
{ id: 'name', label: t('Customer') },
{ id: 'createdAt', label: t('Date'), width: 140 },
{ id: 'totalQuantity', label: t('Items'), width: 120, align: 'center' },
{ id: 'totalAmount', label: t('Price'), width: 140 },
{ id: 'status', label: t('Status'), width: 110 },
{ id: '', width: 88 },
];
const { partyOrders, partyOrdersLoading } = useGetPartyOrders();
const table = useTable({ defaultOrderBy: 'orderNumber' });
const confirmDialog = useBoolean();
const [tableData, setTableData] = useState<IPartyOrderItem[]>([]);
useEffect(() => {
setTableData(partyOrders);
}, [partyOrders]);
const filters = useSetState<IPartyOrderTableFilters>({
name: '',
status: 'all',
startDate: null,
endDate: null,
});
const { state: currentFilters, setState: updateFilters } = filters;
const dateError = fIsAfter(currentFilters.startDate, currentFilters.endDate);
const dataFiltered = applyFilter({
inputData: tableData,
comparator: getComparator(table.order, table.orderBy),
filters: currentFilters,
dateError,
});
const dataInPage = rowInPage(dataFiltered, table.page, table.rowsPerPage);
const canReset =
!!currentFilters.name ||
currentFilters.status !== 'all' ||
(!!currentFilters.startDate && !!currentFilters.endDate);
const notFound = (!dataFiltered.length && canReset) || !dataFiltered.length;
const handleDeleteRow = useCallback(
async (id: string) => {
// const deleteRow = tableData.filter((row) => row.id !== id);
try {
await deletePartyOrder(id);
toast.success('Delete success!');
} catch (error) {
console.error(error);
toast.error('Delete failed!');
}
// toast.success('Delete success!');
// setTableData(deleteRow);
// table.onUpdatePageDeleteRow(dataInPage.length);
},
[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>
}
/>
);
// TODO: remove below loading screen as mutate is not used
if (!partyOrders) return <>loading</>;
if (partyOrdersLoading) return <>loading</>;
return (
<>
<DashboardContent>
<CustomBreadcrumbs
heading="List"
links={[
//
{ name: t('Dashboard'), href: paths.dashboard.root },
{ name: t('Party Order'), href: paths.dashboard.partyOrder.root },
{ name: t('List') },
]}
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 === 'completed' && 'success') ||
(tab.value === 'pending' && 'warning') ||
(tab.value === 'cancelled' && 'error') ||
'default'
}
>
{['completed', 'pending', 'cancelled', 'refunded'].includes(tab.value)
? tableData.filter((user) => user.status === tab.value).length
: tableData.length}
</Label>
}
/>
))}
</Tabs>
<PartyOrderTableToolbar
filters={filters}
onResetPage={table.onResetPage}
dateError={dateError}
/>
{canReset && (
<PartyOrderTableFiltersResult
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 sx={{ minHeight: 444 }}>
<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) => (
<PartyOrderTableRow
key={row.id}
row={row}
selected={table.selected.includes(row.id)}
onSelectRow={() => table.onSelectRow(row.id)}
onDeleteRow={() => handleDeleteRow(row.id)}
detailsHref={paths.dashboard.partyOrder.details(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 = {
dateError: boolean;
inputData: IPartyOrderItem[];
filters: IPartyOrderTableFilters;
comparator: (a: any, b: any) => number;
};
function applyFilter({ inputData, comparator, filters, dateError }: ApplyFilterProps) {
const { status, name, startDate, endDate } = 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(({ orderNumber, customer }) =>
[orderNumber, customer.name, customer.email].some((field) =>
field?.toLowerCase().includes(name.toLowerCase())
)
);
}
if (status !== 'all') {
inputData = inputData.filter((order) => order.status === status);
}
if (!dateError) {
if (startDate && endDate) {
inputData = inputData.filter((order) => fIsBetween(order.createdAt, startDate, endDate));
}
}
return inputData;
}

View File

@@ -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' }}
/>
</>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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()}
</>
);
}

View File

@@ -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()}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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()))
);
}

Some files were not shown because too many files have changed in this diff Show More