2
This commit is contained in:
parent
45dc3fadc9
commit
e8ed8d174b
File diff suppressed because it is too large
Load Diff
@ -6,50 +6,36 @@ const { checkPermissions } = require('../middlewares/check-permissions');
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const workspacePermissions = [
|
const workspacePermissions = ['READ_BUYER_PORTAL'];
|
||||||
'READ_ACCOUNTS',
|
|
||||||
'READ_LOCATIONS',
|
|
||||||
'READ_CONTACTS',
|
|
||||||
'READ_PRODUCTS',
|
|
||||||
'READ_PRICE_LISTS',
|
|
||||||
'READ_PRICE_LIST_ITEMS',
|
|
||||||
'READ_ACCOUNT_PRICE_LISTS',
|
|
||||||
'READ_ORDERS',
|
|
||||||
'READ_ORDER_ITEMS',
|
|
||||||
'READ_QUOTES',
|
|
||||||
'READ_QUOTE_ITEMS',
|
|
||||||
'READ_SAMPLE_REQUESTS',
|
|
||||||
'READ_SAVED_LISTS',
|
|
||||||
'READ_SAVED_LIST_ITEMS',
|
|
||||||
];
|
|
||||||
|
|
||||||
const createSavedListPermissions = [
|
const createSavedListPermissions = [
|
||||||
|
'READ_BUYER_PORTAL',
|
||||||
'CREATE_SAVED_LISTS',
|
'CREATE_SAVED_LISTS',
|
||||||
'CREATE_SAVED_LIST_ITEMS',
|
'CREATE_SAVED_LIST_ITEMS',
|
||||||
'READ_ACCOUNTS',
|
|
||||||
'READ_PRODUCTS',
|
|
||||||
'READ_PRICE_LIST_ITEMS',
|
|
||||||
'READ_ACCOUNT_PRICE_LISTS',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const createOrderPermissions = [
|
const createOrderPermissions = [
|
||||||
|
'READ_BUYER_PORTAL',
|
||||||
'CREATE_ORDERS',
|
'CREATE_ORDERS',
|
||||||
'CREATE_ORDER_ITEMS',
|
'CREATE_ORDER_ITEMS',
|
||||||
'READ_ACCOUNTS',
|
];
|
||||||
'READ_LOCATIONS',
|
|
||||||
'READ_PRODUCTS',
|
const createSampleRequestPermissions = [
|
||||||
'READ_PRICE_LIST_ITEMS',
|
'READ_BUYER_PORTAL',
|
||||||
'READ_ACCOUNT_PRICE_LISTS',
|
'CREATE_SAMPLE_REQUESTS',
|
||||||
];
|
];
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/workspace',
|
'/workspace',
|
||||||
...workspacePermissions.map((permission) => checkPermissions(permission)),
|
...workspacePermissions.map((permission) => checkPermissions(permission)),
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const workspace = await BuyerPortalService.workspace({
|
const workspace = await BuyerPortalService.workspace(
|
||||||
accountId: req.query.accountId,
|
{
|
||||||
locationId: req.query.locationId,
|
accountId: req.query.accountId,
|
||||||
});
|
locationId: req.query.locationId,
|
||||||
|
},
|
||||||
|
req.currentUser,
|
||||||
|
);
|
||||||
|
|
||||||
res.status(200).send(workspace);
|
res.status(200).send(workspace);
|
||||||
}),
|
}),
|
||||||
@ -83,4 +69,19 @@ router.post(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/sample-requests',
|
||||||
|
...createSampleRequestPermissions.map((permission) =>
|
||||||
|
checkPermissions(permission),
|
||||||
|
),
|
||||||
|
wrapAsync(async (req, res) => {
|
||||||
|
const sampleRequest = await BuyerPortalService.createSampleRequest(
|
||||||
|
req.body,
|
||||||
|
req.currentUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).send({ sampleRequest });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@ -19,9 +19,66 @@ function toNumber(value) {
|
|||||||
function makeError(message) {
|
function makeError(message) {
|
||||||
const error = new Error(message);
|
const error = new Error(message);
|
||||||
error.code = 400;
|
error.code = 400;
|
||||||
|
error.status = 400;
|
||||||
|
error.statusCode = 400;
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BUYER_ROLE_NAMES = ['Customer Buyer Admin', 'Customer Buyer'];
|
||||||
|
|
||||||
|
function isCustomerBuyer(currentUser) {
|
||||||
|
return currentUser?.app_role?.name === 'Customer Buyer';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCustomerBuyerAdmin(currentUser) {
|
||||||
|
return currentUser?.app_role?.name === 'Customer Buyer Admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBuyerRole(currentUser) {
|
||||||
|
return BUYER_ROLE_NAMES.includes(currentUser?.app_role?.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBuyerAccountIds(currentUser) {
|
||||||
|
if (!isBuyerRole(currentUser)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentUser.email) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const contacts = await db.contacts.findAll({
|
||||||
|
where: {
|
||||||
|
email: {
|
||||||
|
[Op.iLike]: currentUser.email,
|
||||||
|
},
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
contacts
|
||||||
|
.map((contact) => contact.accountId)
|
||||||
|
.filter((accountId) => Boolean(accountId)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertAccountAccess(accountId, currentUser) {
|
||||||
|
const buyerAccountIds = await getBuyerAccountIds(currentUser);
|
||||||
|
|
||||||
|
if (buyerAccountIds === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!buyerAccountIds.includes(accountId)) {
|
||||||
|
throw makeError(
|
||||||
|
'Your buyer account is not allowed to access the selected customer account.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isCurrent(start, end) {
|
function isCurrent(start, end) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
@ -48,6 +105,18 @@ function buildOrderNumber() {
|
|||||||
return `ORD-${stamp}-${suffix}`;
|
return `ORD-${stamp}-${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSampleRequestNumber() {
|
||||||
|
const now = new Date();
|
||||||
|
const stamp = [
|
||||||
|
now.getUTCFullYear(),
|
||||||
|
String(now.getUTCMonth() + 1).padStart(2, '0'),
|
||||||
|
String(now.getUTCDate()).padStart(2, '0'),
|
||||||
|
].join('');
|
||||||
|
const suffix = String(Math.floor(1000 + Math.random() * 9000));
|
||||||
|
|
||||||
|
return `SR-${stamp}-${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
function serializeUser(user) {
|
function serializeUser(user) {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return null;
|
return null;
|
||||||
@ -284,14 +353,23 @@ async function ensureBuyerPortalSeeded() {
|
|||||||
await buyerPortalSeedPromise;
|
await buyerPortalSeedPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAccounts() {
|
async function getAccounts(currentUser) {
|
||||||
const accounts = await db.accounts.findAll({
|
const buyerAccountIds = await getBuyerAccountIds(currentUser);
|
||||||
where: {
|
const where = {
|
||||||
is_active: true,
|
is_active: true,
|
||||||
default_price_listId: {
|
default_price_listId: {
|
||||||
[Op.ne]: null,
|
[Op.ne]: null,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (buyerAccountIds !== null) {
|
||||||
|
where.id = {
|
||||||
|
[Op.in]: buyerAccountIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = await db.accounts.findAll({
|
||||||
|
where,
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: db.price_lists,
|
model: db.price_lists,
|
||||||
@ -517,11 +595,17 @@ async function getCatalogForAccount(account) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRecentOrders(accountId) {
|
async function getRecentOrders(accountId, currentUser) {
|
||||||
|
const where = {
|
||||||
|
accountId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isCustomerBuyer(currentUser)) {
|
||||||
|
where.buyerId = currentUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
const orders = await db.orders.findAll({
|
const orders = await db.orders.findAll({
|
||||||
where: {
|
where,
|
||||||
accountId,
|
|
||||||
},
|
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: db.locations,
|
model: db.locations,
|
||||||
@ -563,11 +647,17 @@ async function getRecentOrders(accountId) {
|
|||||||
return orders.map((order) => serializeOrder(order.get({ plain: true })));
|
return orders.map((order) => serializeOrder(order.get({ plain: true })));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRecentQuotes(accountId) {
|
async function getRecentQuotes(accountId, currentUser) {
|
||||||
|
const where = {
|
||||||
|
accountId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isCustomerBuyer(currentUser)) {
|
||||||
|
where.requested_byId = currentUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
const quotes = await db.quotes.findAll({
|
const quotes = await db.quotes.findAll({
|
||||||
where: {
|
where,
|
||||||
accountId,
|
|
||||||
},
|
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: db.locations,
|
model: db.locations,
|
||||||
@ -614,11 +704,17 @@ async function getRecentQuotes(accountId) {
|
|||||||
return quotes.map((quote) => serializeQuote(quote.get({ plain: true })));
|
return quotes.map((quote) => serializeQuote(quote.get({ plain: true })));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRecentSamples(accountId) {
|
async function getRecentSamples(accountId, currentUser) {
|
||||||
|
const where = {
|
||||||
|
accountId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isCustomerBuyer(currentUser)) {
|
||||||
|
where.requested_byId = currentUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
const sampleRequests = await db.sample_requests.findAll({
|
const sampleRequests = await db.sample_requests.findAll({
|
||||||
where: {
|
where,
|
||||||
accountId,
|
|
||||||
},
|
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: db.products,
|
model: db.products,
|
||||||
@ -655,11 +751,17 @@ async function getRecentSamples(accountId) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSavedLists(accountId) {
|
async function getSavedLists(accountId, currentUser) {
|
||||||
|
const where = {
|
||||||
|
accountId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isCustomerBuyer(currentUser)) {
|
||||||
|
where.ownerId = currentUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
const savedLists = await db.saved_lists.findAll({
|
const savedLists = await db.saved_lists.findAll({
|
||||||
where: {
|
where,
|
||||||
accountId,
|
|
||||||
},
|
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: db.users,
|
model: db.users,
|
||||||
@ -692,10 +794,10 @@ async function getSavedLists(accountId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = class BuyerPortalService {
|
module.exports = class BuyerPortalService {
|
||||||
static async workspace(filter = {}) {
|
static async workspace(filter = {}, currentUser) {
|
||||||
await ensureBuyerPortalSeeded();
|
await ensureBuyerPortalSeeded();
|
||||||
|
|
||||||
const accounts = await getAccounts();
|
const accounts = await getAccounts(currentUser);
|
||||||
const accountIds = accounts.map((account) => account.id);
|
const accountIds = accounts.map((account) => account.id);
|
||||||
const locations = await getLocations(accountIds);
|
const locations = await getLocations(accountIds);
|
||||||
const contacts = await getContacts(accountIds);
|
const contacts = await getContacts(accountIds);
|
||||||
@ -721,16 +823,16 @@ module.exports = class BuyerPortalService {
|
|||||||
|
|
||||||
const catalog = await getCatalogForAccount(selectedAccountWithPricing);
|
const catalog = await getCatalogForAccount(selectedAccountWithPricing);
|
||||||
const recentOrders = selectedAccount
|
const recentOrders = selectedAccount
|
||||||
? await getRecentOrders(selectedAccount.id)
|
? await getRecentOrders(selectedAccount.id, currentUser)
|
||||||
: [];
|
: [];
|
||||||
const recentQuotes = selectedAccount
|
const recentQuotes = selectedAccount
|
||||||
? await getRecentQuotes(selectedAccount.id)
|
? await getRecentQuotes(selectedAccount.id, currentUser)
|
||||||
: [];
|
: [];
|
||||||
const recentSamples = selectedAccount
|
const recentSamples = selectedAccount
|
||||||
? await getRecentSamples(selectedAccount.id)
|
? await getRecentSamples(selectedAccount.id, currentUser)
|
||||||
: [];
|
: [];
|
||||||
const savedLists = selectedAccount
|
const savedLists = selectedAccount
|
||||||
? await getSavedLists(selectedAccount.id)
|
? await getSavedLists(selectedAccount.id, currentUser)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -742,6 +844,10 @@ module.exports = class BuyerPortalService {
|
|||||||
recentQuotes,
|
recentQuotes,
|
||||||
recentSamples,
|
recentSamples,
|
||||||
savedLists,
|
savedLists,
|
||||||
|
roleContext: {
|
||||||
|
isBuyerAdmin: isCustomerBuyerAdmin(currentUser),
|
||||||
|
isBuyerUser: isCustomerBuyer(currentUser),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -756,6 +862,8 @@ module.exports = class BuyerPortalService {
|
|||||||
throw makeError('Choose an account before saving a reorder list.');
|
throw makeError('Choose an account before saving a reorder list.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await assertAccountAccess(payload.accountId, currentUser);
|
||||||
|
|
||||||
if (!payload.listName || !String(payload.listName).trim()) {
|
if (!payload.listName || !String(payload.listName).trim()) {
|
||||||
throw makeError('Give this reorder list a name.');
|
throw makeError('Give this reorder list a name.');
|
||||||
}
|
}
|
||||||
@ -893,6 +1001,8 @@ module.exports = class BuyerPortalService {
|
|||||||
throw makeError('Choose an account before placing an order.');
|
throw makeError('Choose an account before placing an order.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await assertAccountAccess(payload.accountId, currentUser);
|
||||||
|
|
||||||
if (!payload.locationId) {
|
if (!payload.locationId) {
|
||||||
throw makeError('Choose a delivery location before placing an order.');
|
throw makeError('Choose a delivery location before placing an order.');
|
||||||
}
|
}
|
||||||
@ -1071,4 +1181,123 @@ module.exports = class BuyerPortalService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async createSampleRequest(payload = {}, currentUser) {
|
||||||
|
await ensureBuyerPortalSeeded();
|
||||||
|
|
||||||
|
if (!currentUser?.id) {
|
||||||
|
throw makeError('You must be signed in to request a sample.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.accountId) {
|
||||||
|
throw makeError('Choose an account before requesting a sample.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertAccountAccess(payload.accountId, currentUser);
|
||||||
|
|
||||||
|
if (!payload.locationId) {
|
||||||
|
throw makeError('Choose a delivery location before requesting a sample.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.productId) {
|
||||||
|
throw makeError('Choose a sample-eligible product.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantity = Number.parseInt(String(payload.sampleQuantity), 10);
|
||||||
|
if (!Number.isInteger(quantity) || quantity <= 0) {
|
||||||
|
throw makeError('Enter a valid sample quantity.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await db.accounts.findOne({
|
||||||
|
where: {
|
||||||
|
id: payload.accountId,
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.price_lists,
|
||||||
|
as: 'default_price_list',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
throw makeError('The selected account could not be found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = await db.locations.findOne({
|
||||||
|
where: {
|
||||||
|
id: payload.locationId,
|
||||||
|
accountId: payload.accountId,
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!location) {
|
||||||
|
throw makeError(
|
||||||
|
'The selected delivery location is invalid for this account.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalog = await getCatalogForAccount(account.get({ plain: true }));
|
||||||
|
const product = catalog.find((item) => item.id === payload.productId);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw makeError(
|
||||||
|
'The selected product is no longer on the contract catalog.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!product.is_sample_eligible) {
|
||||||
|
throw makeError(`${product.product_name} is not sample eligible.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleRequest = await db.sample_requests.create({
|
||||||
|
sample_request_number: buildSampleRequestNumber(),
|
||||||
|
sample_quantity: quantity,
|
||||||
|
requested_at: new Date(),
|
||||||
|
needed_by: payload.neededBy || null,
|
||||||
|
sample_status: 'requested',
|
||||||
|
notes: payload.notes || null,
|
||||||
|
accountId: account.id,
|
||||||
|
locationId: location.id,
|
||||||
|
requested_byId: currentUser.id,
|
||||||
|
productId: product.id,
|
||||||
|
createdById: currentUser.id,
|
||||||
|
updatedById: currentUser.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdSampleRequest = await db.sample_requests.findByPk(
|
||||||
|
sampleRequest.id,
|
||||||
|
{
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.products,
|
||||||
|
as: 'product',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.locations,
|
||||||
|
as: 'location',
|
||||||
|
required: false,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.contacts,
|
||||||
|
as: 'default_contact',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.users,
|
||||||
|
as: 'requested_by',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return serializeSampleRequest(createdSampleRequest.get({ plain: true }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
1
frontend/public/locales/en-GB/translation.json
Normal file
1
frontend/public/locales/en-GB/translation.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
1
frontend/public/locales/en-US/translation.json
Normal file
1
frontend/public/locales/en-US/translation.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
1
frontend/public/locales/en/translation.json
Normal file
1
frontend/public/locales/en/translation.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
BIN
frontend/src/._colors.ts
Normal file
BIN
frontend/src/._colors.ts
Normal file
Binary file not shown.
BIN
frontend/src/._menuAside.ts
Normal file
BIN
frontend/src/._menuAside.ts
Normal file
Binary file not shown.
BIN
frontend/src/._styles.ts
Normal file
BIN
frontend/src/._styles.ts
Normal file
Binary file not shown.
@ -1,21 +1,22 @@
|
|||||||
import type { ColorButtonKey } from './interfaces'
|
import type { ColorButtonKey } from './interfaces';
|
||||||
|
|
||||||
export const gradientBgBase = 'bg-gradient-to-tr'
|
export const gradientBgBase = 'bg-gradient-to-tr';
|
||||||
export const colorBgBase = "bg-violet-50/50"
|
export const colorBgBase = 'bg-violet-50/50';
|
||||||
export const gradientBgPurplePink = `${gradientBgBase} from-purple-400 via-pink-500 to-red-500`
|
export const gradientBgPurplePink = `${gradientBgBase} from-purple-400 via-pink-500 to-red-500`;
|
||||||
export const gradientBgViolet = `${gradientBgBase} ${colorBgBase}`
|
export const gradientBgViolet = `${gradientBgBase} ${colorBgBase}`;
|
||||||
export const gradientBgDark = `${gradientBgBase} from-dark-700 via-dark-900 to-dark-800`;
|
export const gradientBgDark = `${gradientBgBase} from-dark-700 via-dark-900 to-dark-800`;
|
||||||
export const gradientBgPinkRed = `${gradientBgBase} from-pink-400 via-red-500 to-yellow-500`
|
export const gradientBgPinkRed = `${gradientBgBase} from-pink-400 via-red-500 to-yellow-500`;
|
||||||
|
|
||||||
export const colorsBgLight = {
|
export const colorsBgLight = {
|
||||||
white: 'bg-white text-black',
|
white: 'bg-white text-black',
|
||||||
light: ' bg-white text-black text-black dark:bg-dark-900 dark:text-white',
|
light: ' bg-white text-black text-black dark:bg-dark-900 dark:text-white',
|
||||||
contrast: 'bg-gray-800 text-white dark:bg-white dark:text-black',
|
contrast: 'bg-gray-800 text-white dark:bg-white dark:text-black',
|
||||||
success: 'bg-emerald-500 border-emerald-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white',
|
success:
|
||||||
|
'bg-emerald-500 border-emerald-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white',
|
||||||
danger: 'bg-red-500 border-red-500 text-white',
|
danger: 'bg-red-500 border-red-500 text-white',
|
||||||
warning: 'bg-yellow-500 border-yellow-500 text-white',
|
warning: 'bg-yellow-500 border-yellow-500 text-white',
|
||||||
info: 'bg-blue-500 border-blue-500 dark:bg-pavitra-blue dark:border-pavitra-blue text-white',
|
info: 'bg-slate-900 border-slate-900 dark:bg-pavitra-blue dark:border-pavitra-blue text-white',
|
||||||
}
|
};
|
||||||
|
|
||||||
export const colorsText = {
|
export const colorsText = {
|
||||||
white: 'text-black dark:text-slate-100',
|
white: 'text-black dark:text-slate-100',
|
||||||
@ -24,13 +25,15 @@ export const colorsText = {
|
|||||||
success: 'text-emerald-500',
|
success: 'text-emerald-500',
|
||||||
danger: 'text-red-500',
|
danger: 'text-red-500',
|
||||||
warning: 'text-yellow-500',
|
warning: 'text-yellow-500',
|
||||||
info: 'text-blue-500',
|
info: 'text-slate-900',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const colorsOutline = {
|
export const colorsOutline = {
|
||||||
white: [colorsText.white, 'border-gray-100'].join(' '),
|
white: [colorsText.white, 'border-gray-100'].join(' '),
|
||||||
light: [colorsText.light, 'border-gray-100'].join(' '),
|
light: [colorsText.light, 'border-gray-100'].join(' '),
|
||||||
contrast: [colorsText.contrast, 'border-gray-900 dark:border-slate-100'].join(' '),
|
contrast: [colorsText.contrast, 'border-gray-900 dark:border-slate-100'].join(
|
||||||
|
' ',
|
||||||
|
),
|
||||||
success: [colorsText.success, 'border-emerald-500'].join(' '),
|
success: [colorsText.success, 'border-emerald-500'].join(' '),
|
||||||
danger: [colorsText.danger, 'border-red-500'].join(' '),
|
danger: [colorsText.danger, 'border-red-500'].join(' '),
|
||||||
warning: [colorsText.warning, 'border-yellow-500'].join(' '),
|
warning: [colorsText.warning, 'border-yellow-500'].join(' '),
|
||||||
@ -41,10 +44,10 @@ export const getButtonColor = (
|
|||||||
color: ColorButtonKey,
|
color: ColorButtonKey,
|
||||||
isOutlined: boolean,
|
isOutlined: boolean,
|
||||||
hasHover: boolean,
|
hasHover: boolean,
|
||||||
isActive = false
|
isActive = false,
|
||||||
) => {
|
) => {
|
||||||
if (color === 'void') {
|
if (color === 'void') {
|
||||||
return ''
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const colors = {
|
const colors = {
|
||||||
@ -56,7 +59,7 @@ export const getButtonColor = (
|
|||||||
success: 'ring-emerald-300 dark:ring-pavitra-blue',
|
success: 'ring-emerald-300 dark:ring-pavitra-blue',
|
||||||
danger: 'ring-red-300 dark:ring-red-700',
|
danger: 'ring-red-300 dark:ring-red-700',
|
||||||
warning: 'ring-yellow-300 dark:ring-yellow-700',
|
warning: 'ring-yellow-300 dark:ring-yellow-700',
|
||||||
info: "ring-blue-300 dark:ring-pavitra-blue",
|
info: 'ring-slate-300 dark:ring-pavitra-blue',
|
||||||
},
|
},
|
||||||
active: {
|
active: {
|
||||||
white: 'bg-gray-100',
|
white: 'bg-gray-100',
|
||||||
@ -66,7 +69,7 @@ export const getButtonColor = (
|
|||||||
success: 'bg-emerald-700 dark:bg-pavitra-blue',
|
success: 'bg-emerald-700 dark:bg-pavitra-blue',
|
||||||
danger: 'bg-red-700 dark:bg-red-600',
|
danger: 'bg-red-700 dark:bg-red-600',
|
||||||
warning: 'bg-yellow-700 dark:bg-yellow-600',
|
warning: 'bg-yellow-700 dark:bg-yellow-600',
|
||||||
info: 'bg-blue-700 dark:bg-pavitra-blue',
|
info: 'bg-slate-800 dark:bg-pavitra-blue',
|
||||||
},
|
},
|
||||||
bg: {
|
bg: {
|
||||||
white: 'bg-white text-black',
|
white: 'bg-white text-black',
|
||||||
@ -76,7 +79,7 @@ export const getButtonColor = (
|
|||||||
success: 'bg-emerald-600 dark:bg-pavitra-blue text-white',
|
success: 'bg-emerald-600 dark:bg-pavitra-blue text-white',
|
||||||
danger: 'bg-red-600 text-white dark:bg-red-500 ',
|
danger: 'bg-red-600 text-white dark:bg-red-500 ',
|
||||||
warning: 'bg-yellow-600 dark:bg-yellow-500 text-white',
|
warning: 'bg-yellow-600 dark:bg-yellow-500 text-white',
|
||||||
info: " bg-blue-600 dark:bg-pavitra-blue text-white ",
|
info: ' bg-slate-900 dark:bg-pavitra-blue text-white ',
|
||||||
},
|
},
|
||||||
bgHover: {
|
bgHover: {
|
||||||
white: 'hover:bg-gray-100',
|
white: 'hover:bg-gray-100',
|
||||||
@ -84,12 +87,12 @@ export const getButtonColor = (
|
|||||||
lightDark: 'hover:bg-gray-200 hover:dark:bg-slate-700',
|
lightDark: 'hover:bg-gray-200 hover:dark:bg-slate-700',
|
||||||
contrast: 'hover:bg-gray-700 hover:dark:bg-slate-100',
|
contrast: 'hover:bg-gray-700 hover:dark:bg-slate-100',
|
||||||
success:
|
success:
|
||||||
'hover:bg-emerald-700 hover:border-emerald-700 hover:dark:bg-pavitra-blue hover:dark:border-pavitra-blue',
|
'hover:bg-emerald-700 hover:border-emerald-700 hover:dark:bg-pavitra-blue hover:dark:border-pavitra-blue',
|
||||||
danger:
|
danger:
|
||||||
'hover:bg-red-700 hover:border-red-700 hover:dark:bg-red-600 hover:dark:border-red-600',
|
'hover:bg-red-700 hover:border-red-700 hover:dark:bg-red-600 hover:dark:border-red-600',
|
||||||
warning:
|
warning:
|
||||||
'hover:bg-yellow-700 hover:border-yellow-700 hover:dark:bg-yellow-600 hover:dark:border-yellow-600',
|
'hover:bg-yellow-700 hover:border-yellow-700 hover:dark:bg-yellow-600 hover:dark:border-yellow-600',
|
||||||
info: "hover:bg-blue-700 hover:border-blue-700 hover:dark:bg-pavitra-blue/80 hover:dark:border-pavitra-blue/80",
|
info: 'hover:bg-slate-800 hover:border-slate-800 hover:dark:bg-pavitra-blue/80 hover:dark:border-pavitra-blue/80',
|
||||||
},
|
},
|
||||||
borders: {
|
borders: {
|
||||||
white: 'border-white',
|
white: 'border-white',
|
||||||
@ -99,40 +102,44 @@ export const getButtonColor = (
|
|||||||
success: 'border-emerald-600 dark:border-pavitra-blue',
|
success: 'border-emerald-600 dark:border-pavitra-blue',
|
||||||
danger: 'border-red-600 dark:border-red-500',
|
danger: 'border-red-600 dark:border-red-500',
|
||||||
warning: 'border-yellow-600 dark:border-yellow-500',
|
warning: 'border-yellow-600 dark:border-yellow-500',
|
||||||
info: "border-blue-600 border-blue-600 dark:border-pavitra-blue",
|
info: 'border-slate-900 dark:border-pavitra-blue',
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
contrast: 'dark:text-slate-100',
|
contrast: 'dark:text-slate-100',
|
||||||
success: 'text-emerald-600 dark:text-pavitra-blue',
|
success: 'text-emerald-600 dark:text-pavitra-blue',
|
||||||
danger: 'text-red-600 dark:text-red-500',
|
danger: 'text-red-600 dark:text-red-500',
|
||||||
warning: 'text-yellow-600 dark:text-yellow-500',
|
warning: 'text-yellow-600 dark:text-yellow-500',
|
||||||
info: 'text-blue-600 dark:text-pavitra-blue',
|
info: 'text-slate-900 dark:text-pavitra-blue',
|
||||||
},
|
},
|
||||||
outlineHover: {
|
outlineHover: {
|
||||||
contrast:
|
contrast:
|
||||||
'hover:bg-gray-800 hover:text-gray-100 hover:dark:bg-slate-100 hover:dark:text-black',
|
'hover:bg-gray-800 hover:text-gray-100 hover:dark:bg-slate-100 hover:dark:text-black',
|
||||||
success: 'hover:bg-emerald-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue',
|
success:
|
||||||
|
'hover:bg-emerald-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue',
|
||||||
danger:
|
danger:
|
||||||
'hover:bg-red-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-red-600',
|
'hover:bg-red-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-red-600',
|
||||||
warning:
|
warning:
|
||||||
'hover:bg-yellow-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-yellow-600',
|
'hover:bg-yellow-600 hover:text-white hover:text-white hover:dark:text-white hover:dark:border-yellow-600',
|
||||||
info: "hover:bg-blue-600 hover:bg-blue-600 hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue",
|
info: 'hover:bg-slate-900 hover:text-white hover:dark:text-white hover:dark:border-pavitra-blue',
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
const isOutlinedProcessed = isOutlined && ['white', 'whiteDark', 'lightDark'].indexOf(color) < 0
|
const isOutlinedProcessed =
|
||||||
|
isOutlined && ['white', 'whiteDark', 'lightDark'].indexOf(color) < 0;
|
||||||
|
|
||||||
const base = [colors.borders[color], colors.ring[color]]
|
const base = [colors.borders[color], colors.ring[color]];
|
||||||
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
base.push(colors.active[color])
|
base.push(colors.active[color]);
|
||||||
} else {
|
} else {
|
||||||
base.push(isOutlinedProcessed ? colors.text[color] : colors.bg[color])
|
base.push(isOutlinedProcessed ? colors.text[color] : colors.bg[color]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasHover) {
|
if (hasHover) {
|
||||||
base.push(isOutlinedProcessed ? colors.outlineHover[color] : colors.bgHover[color])
|
base.push(
|
||||||
|
isOutlinedProcessed ? colors.outlineHover[color] : colors.bgHover[color],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return base.join(' ')
|
return base.join(' ');
|
||||||
}
|
};
|
||||||
|
|||||||
BIN
frontend/src/components/._AsideMenu.tsx
Normal file
BIN
frontend/src/components/._AsideMenu.tsx
Normal file
Binary file not shown.
BIN
frontend/src/components/._AsideMenuItem.tsx
Normal file
BIN
frontend/src/components/._AsideMenuItem.tsx
Normal file
Binary file not shown.
BIN
frontend/src/components/._AsideMenuLayer.tsx
Normal file
BIN
frontend/src/components/._AsideMenuLayer.tsx
Normal file
Binary file not shown.
BIN
frontend/src/components/._AsideMenuList.tsx
Normal file
BIN
frontend/src/components/._AsideMenuList.tsx
Normal file
Binary file not shown.
BIN
frontend/src/components/._CardBox.tsx
Normal file
BIN
frontend/src/components/._CardBox.tsx
Normal file
Binary file not shown.
BIN
frontend/src/components/._FooterBar.tsx
Normal file
BIN
frontend/src/components/._FooterBar.tsx
Normal file
Binary file not shown.
BIN
frontend/src/components/._NavBar.tsx
Normal file
BIN
frontend/src/components/._NavBar.tsx
Normal file
Binary file not shown.
BIN
frontend/src/components/._NavBarItem.tsx
Normal file
BIN
frontend/src/components/._NavBarItem.tsx
Normal file
Binary file not shown.
BIN
frontend/src/components/._NavBarItemPlain.tsx
Normal file
BIN
frontend/src/components/._NavBarItemPlain.tsx
Normal file
Binary file not shown.
BIN
frontend/src/components/._Search.tsx
Normal file
BIN
frontend/src/components/._Search.tsx
Normal file
Binary file not shown.
BIN
frontend/src/components/._SectionMain.tsx
Normal file
BIN
frontend/src/components/._SectionMain.tsx
Normal file
Binary file not shown.
BIN
frontend/src/components/._SectionTitleLineWithButton.tsx
Normal file
BIN
frontend/src/components/._SectionTitleLineWithButton.tsx
Normal file
Binary file not shown.
@ -1,14 +1,14 @@
|
|||||||
import React from 'react'
|
import React from 'react';
|
||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces';
|
||||||
import AsideMenuLayer from './AsideMenuLayer'
|
import AsideMenuLayer from './AsideMenuLayer';
|
||||||
import OverlayLayer from './OverlayLayer'
|
import OverlayLayer from './OverlayLayer';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
menu: MenuAsideItem[]
|
menu: MenuAsideItem[];
|
||||||
isAsideMobileExpanded: boolean
|
isAsideMobileExpanded: boolean;
|
||||||
isAsideLgActive: boolean
|
isAsideLgActive: boolean;
|
||||||
onAsideLgClose: () => void
|
onAsideLgClose: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function AsideMenu({
|
export default function AsideMenu({
|
||||||
isAsideMobileExpanded = false,
|
isAsideMobileExpanded = false,
|
||||||
@ -19,12 +19,14 @@ export default function AsideMenu({
|
|||||||
<>
|
<>
|
||||||
<AsideMenuLayer
|
<AsideMenuLayer
|
||||||
menu={props.menu}
|
menu={props.menu}
|
||||||
className={`${isAsideMobileExpanded ? 'left-0' : '-left-60 lg:left-0'} ${
|
className={`${
|
||||||
!isAsideLgActive ? 'lg:hidden xl:flex' : ''
|
isAsideMobileExpanded ? 'left-0' : '-left-72 lg:left-0'
|
||||||
}`}
|
} ${!isAsideLgActive ? 'lg:hidden xl:flex' : ''}`}
|
||||||
onAsideLgCloseClick={props.onAsideLgClose}
|
onAsideLgCloseClick={props.onAsideLgClose}
|
||||||
/>
|
/>
|
||||||
{isAsideLgActive && <OverlayLayer zIndex="z-30" onClick={props.onAsideLgClose} />}
|
{isAsideLgActive && (
|
||||||
|
<OverlayLayer zIndex='z-30' onClick={props.onAsideLgClose} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,102 +1,108 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react';
|
||||||
import { mdiMinus, mdiPlus } from '@mdi/js'
|
import { mdiChevronDown, mdiChevronRight } from '@mdi/js';
|
||||||
import BaseIcon from './BaseIcon'
|
import Link from 'next/link';
|
||||||
import Link from 'next/link'
|
import { useRouter } from 'next/router';
|
||||||
import { getButtonColor } from '../colors'
|
import BaseIcon from './BaseIcon';
|
||||||
import AsideMenuList from './AsideMenuList'
|
import AsideMenuList from './AsideMenuList';
|
||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces';
|
||||||
import { useAppSelector } from '../stores/hooks'
|
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: MenuAsideItem
|
item: MenuAsideItem;
|
||||||
isDropdownList?: boolean
|
isDropdownList?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const getViewName = (href: string) => {
|
||||||
|
return new URL(href, location.href).pathname.split('/')[1];
|
||||||
|
};
|
||||||
|
|
||||||
const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
|
const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
|
||||||
const [isLinkActive, setIsLinkActive] = useState(false)
|
const [isLinkActive, setIsLinkActive] = useState(false);
|
||||||
const [isDropdownActive, setIsDropdownActive] = useState(false)
|
const [isDropdownActive, setIsDropdownActive] = useState(false);
|
||||||
|
const { asPath, isReady } = useRouter();
|
||||||
const asideMenuItemStyle = useAppSelector((state) => state.style.asideMenuItemStyle)
|
|
||||||
const asideMenuDropdownStyle = useAppSelector((state) => state.style.asideMenuDropdownStyle)
|
|
||||||
const asideMenuItemActiveStyle = useAppSelector((state) => state.style.asideMenuItemActiveStyle)
|
|
||||||
const borders = useAppSelector((state) => state.style.borders);
|
|
||||||
const activeLinkColor = useAppSelector(
|
|
||||||
(state) => state.style.activeLinkColor,
|
|
||||||
);
|
|
||||||
const activeClassAddon = !item.color && isLinkActive ? asideMenuItemActiveStyle : ''
|
|
||||||
|
|
||||||
const { asPath, isReady } = useRouter()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item.href && isReady) {
|
if (!isReady) {
|
||||||
const linkPathName = new URL(item.href, location.href).pathname + '/';
|
return;
|
||||||
const activePathname = new URL(asPath, location.href).pathname
|
|
||||||
|
|
||||||
const activeView = activePathname.split('/')[1];
|
|
||||||
const linkPathNameView = linkPathName.split('/')[1];
|
|
||||||
|
|
||||||
setIsLinkActive(linkPathNameView === activeView);
|
|
||||||
}
|
}
|
||||||
}, [item.href, isReady, asPath])
|
|
||||||
|
const activeView = new URL(asPath, location.href).pathname.split('/')[1];
|
||||||
|
const currentItemView = item.href ? getViewName(item.href) : '';
|
||||||
|
const activeChild = (item.menu || []).some((child) => {
|
||||||
|
if (!child.href) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getViewName(child.href) === activeView;
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsLinkActive(currentItemView === activeView || activeChild);
|
||||||
|
|
||||||
|
if (activeChild) {
|
||||||
|
setIsDropdownActive(true);
|
||||||
|
}
|
||||||
|
}, [item.href, item.menu, isReady, asPath]);
|
||||||
|
|
||||||
|
const activeClass = isLinkActive
|
||||||
|
? 'bg-emerald-50 text-slate-950 ring-1 ring-emerald-200'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-dark-800 dark:hover:text-white';
|
||||||
|
|
||||||
|
const componentClass = [
|
||||||
|
'group flex min-h-10 cursor-pointer items-center rounded-xl px-3 py-2 text-sm font-semibold transition-colors',
|
||||||
|
isDropdownList ? 'ml-3 text-[13px]' : '',
|
||||||
|
item.color ? '' : activeClass,
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
const iconClass = isLinkActive
|
||||||
|
? 'text-emerald-700'
|
||||||
|
: 'text-slate-400 group-hover:text-slate-700';
|
||||||
|
|
||||||
const asideMenuItemInnerContents = (
|
const asideMenuItemInnerContents = (
|
||||||
<>
|
<>
|
||||||
{item.icon && (
|
{item.icon && (
|
||||||
<BaseIcon path={item.icon} className={`flex-none mx-3 ${activeClassAddon}`} size="18" />
|
<BaseIcon
|
||||||
|
path={item.icon}
|
||||||
|
className={`mr-3 flex-none ${iconClass}`}
|
||||||
|
size='18'
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<span
|
<span className='grow text-ellipsis line-clamp-1'>{item.label}</span>
|
||||||
className={`grow text-ellipsis line-clamp-1 ${
|
|
||||||
item.menu ? '' : 'pr-12'
|
|
||||||
} ${activeClassAddon}`}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
{item.menu && (
|
{item.menu && (
|
||||||
<BaseIcon
|
<BaseIcon
|
||||||
path={isDropdownActive ? mdiMinus : mdiPlus}
|
path={isDropdownActive ? mdiChevronDown : mdiChevronRight}
|
||||||
className={`flex-none ${activeClassAddon}`}
|
className={`flex-none ${iconClass}`}
|
||||||
w="w-12"
|
w='w-5'
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
|
|
||||||
const componentClass = [
|
|
||||||
'flex cursor-pointer py-1.5 ',
|
|
||||||
isDropdownList ? 'px-6 text-sm' : '',
|
|
||||||
item.color
|
|
||||||
? getButtonColor(item.color, false, true)
|
|
||||||
: `${asideMenuItemStyle}`,
|
|
||||||
isLinkActive
|
|
||||||
? `text-black ${activeLinkColor} dark:text-white dark:bg-dark-800`
|
|
||||||
: '',
|
|
||||||
].join(' ');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={'px-3 py-1.5'}>
|
<li className='py-0.5'>
|
||||||
{item.withDevider && <hr className={`${borders} mb-3`} />}
|
{item.withDevider && <hr className='mb-3 border-slate-200' />}
|
||||||
{item.href && (
|
{item.href && (
|
||||||
<Link href={item.href} target={item.target} className={componentClass}>
|
<Link href={item.href} target={item.target} className={componentClass}>
|
||||||
{asideMenuItemInnerContents}
|
{asideMenuItemInnerContents}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{!item.href && (
|
{!item.href && (
|
||||||
<div className={componentClass} onClick={() => setIsDropdownActive(!isDropdownActive)}>
|
<div
|
||||||
|
className={componentClass}
|
||||||
|
onClick={() => setIsDropdownActive(!isDropdownActive)}
|
||||||
|
>
|
||||||
{asideMenuItemInnerContents}
|
{asideMenuItemInnerContents}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.menu && (
|
{item.menu && (
|
||||||
<AsideMenuList
|
<AsideMenuList
|
||||||
menu={item.menu}
|
menu={item.menu}
|
||||||
className={`${asideMenuDropdownStyle} ${
|
className={`${
|
||||||
isDropdownActive ? 'block dark:bg-slate-800/50' : 'hidden'
|
isDropdownActive ? 'block' : 'hidden'
|
||||||
}`}
|
} mt-1 space-y-1 border-l border-slate-200 pl-2`}
|
||||||
isDropdownList
|
isDropdownList
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default AsideMenuItem
|
export default AsideMenuItem;
|
||||||
|
|||||||
@ -1,63 +1,79 @@
|
|||||||
import React from 'react'
|
import React from 'react';
|
||||||
import { mdiLogout, mdiClose } from '@mdi/js'
|
import { mdiClose } from '@mdi/js';
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon';
|
||||||
import AsideMenuList from './AsideMenuList'
|
import AsideMenuList from './AsideMenuList';
|
||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces';
|
||||||
import { useAppSelector } from '../stores/hooks'
|
import { useAppSelector } from '../stores/hooks';
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
menu: MenuAsideItem[]
|
menu: MenuAsideItem[];
|
||||||
className?: string
|
className?: string;
|
||||||
onAsideLgCloseClick: () => void
|
onAsideLgCloseClick: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function AsideMenuLayer({ menu, className = '', ...props }: Props) {
|
export default function AsideMenuLayer({
|
||||||
const corners = useAppSelector((state) => state.style.corners);
|
menu,
|
||||||
const asideStyle = useAppSelector((state) => state.style.asideStyle)
|
className = '',
|
||||||
const asideBrandStyle = useAppSelector((state) => state.style.asideBrandStyle)
|
...props
|
||||||
const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle)
|
}: Props) {
|
||||||
const darkMode = useAppSelector((state) => state.style.darkMode)
|
const asideStyle = useAppSelector((state) => state.style.asideStyle);
|
||||||
|
const asideScrollbarsStyle = useAppSelector(
|
||||||
|
(state) => state.style.asideScrollbarsStyle,
|
||||||
|
);
|
||||||
|
const darkMode = useAppSelector((state) => state.style.darkMode);
|
||||||
|
|
||||||
const handleAsideLgCloseClick = (e: React.MouseEvent) => {
|
const handleAsideLgCloseClick = (e: React.MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
props.onAsideLgCloseClick()
|
props.onAsideLgCloseClick();
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
id='asideMenu'
|
id='asideMenu'
|
||||||
className={`${className} zzz lg:py-2 lg:pl-2 w-60 fixed flex z-40 top-0 h-screen transition-position overflow-hidden`}
|
className={`${className} fixed top-0 z-40 flex h-screen w-72 overflow-hidden transition-position`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex-1 flex flex-col overflow-hidden dark:bg-dark-900 ${asideStyle} ${corners}`}
|
className={`flex flex-1 flex-col overflow-hidden ${asideStyle} dark:bg-dark-900`}
|
||||||
>
|
>
|
||||||
<div
|
<div className='border-b border-slate-200 px-5 py-5'>
|
||||||
className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`}
|
<div className='flex items-start justify-between gap-3'>
|
||||||
>
|
<div>
|
||||||
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
|
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-emerald-700'>
|
||||||
|
Northstar
|
||||||
<b className="font-black">B2B Distributor Portal</b>
|
</p>
|
||||||
|
<h2 className='mt-2 text-lg font-bold text-slate-950 dark:text-white'>
|
||||||
|
Foodservice Ops
|
||||||
|
</h2>
|
||||||
|
<p className='mt-1 text-xs font-medium text-slate-500'>
|
||||||
|
Supplier / Distributor B2B Portal
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className='hidden rounded-lg border border-slate-200 p-2 text-slate-500 hover:bg-slate-100 lg:inline-block xl:hidden'
|
||||||
|
onClick={handleAsideLgCloseClick}
|
||||||
|
>
|
||||||
|
<BaseIcon path={mdiClose} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
className="hidden lg:inline-block xl:hidden p-3"
|
|
||||||
onClick={handleAsideLgCloseClick}
|
|
||||||
>
|
|
||||||
<BaseIcon path={mdiClose} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`flex-1 overflow-y-auto overflow-x-hidden ${
|
className={`flex-1 overflow-y-auto overflow-x-hidden px-3 py-4 ${
|
||||||
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
|
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<AsideMenuList menu={menu} />
|
<AsideMenuList menu={menu} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='border-t border-slate-200 px-5 py-4'>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400'>
|
||||||
|
Workspace
|
||||||
|
</p>
|
||||||
|
<p className='mt-1 text-sm font-semibold text-slate-700 dark:text-slate-200'>
|
||||||
|
Contract catalog + orders
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,35 +1,37 @@
|
|||||||
import React from 'react'
|
import React from 'react';
|
||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces';
|
||||||
import AsideMenuItem from './AsideMenuItem'
|
import AsideMenuItem from './AsideMenuItem';
|
||||||
import {useAppSelector} from "../stores/hooks";
|
import { useAppSelector } from '../stores/hooks';
|
||||||
import {hasPermission} from "../helpers/userPermissions";
|
import { hasPermission } from '../helpers/userPermissions';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
menu: MenuAsideItem[]
|
menu: MenuAsideItem[];
|
||||||
isDropdownList?: boolean
|
isDropdownList?: boolean;
|
||||||
className?: string
|
className?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function AsideMenuList({ menu, isDropdownList = false, className = '' }: Props) {
|
export default function AsideMenuList({
|
||||||
|
menu,
|
||||||
|
isDropdownList = false,
|
||||||
|
className = '',
|
||||||
|
}: Props) {
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
|
||||||
if (!currentUser) return null;
|
if (!currentUser) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className={className}>
|
<ul className={className || 'space-y-1'}>
|
||||||
{menu.map((item, index) => {
|
{menu.map((item, index) => {
|
||||||
|
if (!hasPermission(currentUser, item.permissions)) return null;
|
||||||
if (!hasPermission(currentUser, item.permissions)) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index}>
|
<AsideMenuItem
|
||||||
<AsideMenuItem
|
key={item.href || item.label || index}
|
||||||
item={item}
|
item={item}
|
||||||
isDropdownList={isDropdownList}
|
isDropdownList={isDropdownList}
|
||||||
/>
|
/>
|
||||||
</div>
|
);
|
||||||
)
|
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,23 @@
|
|||||||
import React, { ReactNode } from 'react'
|
import React, { ReactNode } from 'react';
|
||||||
import CardBoxComponentBody from './CardBoxComponentBody'
|
import CardBoxComponentBody from './CardBoxComponentBody';
|
||||||
import CardBoxComponentFooter from './CardBoxComponentFooter'
|
import CardBoxComponentFooter from './CardBoxComponentFooter';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
import { useAppSelector } from '../stores/hooks';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
rounded?: string
|
rounded?: string;
|
||||||
flex?: string
|
flex?: string;
|
||||||
className?: string
|
className?: string;
|
||||||
hasComponentLayout?: boolean
|
hasComponentLayout?: boolean;
|
||||||
cardBoxClassName?: string
|
cardBoxClassName?: string;
|
||||||
hasTable?: boolean
|
hasTable?: boolean;
|
||||||
isHoverable?: boolean
|
isHoverable?: boolean;
|
||||||
isModal?: boolean
|
isModal?: boolean;
|
||||||
children?: ReactNode
|
children?: ReactNode;
|
||||||
footer?: ReactNode
|
footer?: ReactNode;
|
||||||
isList?:boolean
|
isList?: boolean;
|
||||||
id?: string;
|
id?: string;
|
||||||
onClick?: (e: React.MouseEvent) => void
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function CardBox({
|
export default function CardBox({
|
||||||
rounded = 'rounded',
|
rounded = 'rounded',
|
||||||
@ -31,22 +31,22 @@ export default function CardBox({
|
|||||||
isModal = false,
|
isModal = false,
|
||||||
children,
|
children,
|
||||||
footer,
|
footer,
|
||||||
id ='',
|
id = '',
|
||||||
onClick,
|
onClick,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const corners = useAppSelector((state) => state.style.corners);
|
const corners = useAppSelector((state) => state.style.corners);
|
||||||
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
|
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
|
||||||
const componentClass = [
|
const componentClass = [
|
||||||
`flex dark:border-dark-700 dark:bg-dark-900`,
|
`flex dark:border-dark-700 dark:bg-dark-900`,
|
||||||
className,
|
className,
|
||||||
corners !== 'rounded-full'? corners : 'rounded-3xl',
|
corners !== 'rounded-full' ? corners : 'rounded-3xl',
|
||||||
flex,
|
flex,
|
||||||
isList ? '' : `${cardsStyle}`,
|
isList ? '' : `${cardsStyle}`,
|
||||||
hasTable ? '' : `border-dark-700 dark:border-dark-700`,
|
hasTable ? '' : `dark:border-dark-700`,
|
||||||
]
|
];
|
||||||
|
|
||||||
if (isHoverable) {
|
if (isHoverable) {
|
||||||
componentClass.push('hover:shadow-lg transition-shadow duration-500')
|
componentClass.push('transition-shadow duration-200 hover:shadow-md');
|
||||||
}
|
}
|
||||||
|
|
||||||
return React.createElement(
|
return React.createElement(
|
||||||
@ -56,9 +56,15 @@ export default function CardBox({
|
|||||||
children
|
children
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CardBoxComponentBody id={id} noPadding={hasTable} className={cardBoxClassName}>{children}</CardBoxComponentBody>
|
<CardBoxComponentBody
|
||||||
|
id={id}
|
||||||
|
noPadding={hasTable}
|
||||||
|
className={cardBoxClassName}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</CardBoxComponentBody>
|
||||||
{footer && <CardBoxComponentFooter>{footer}</CardBoxComponentFooter>}
|
{footer && <CardBoxComponentFooter>{footer}</CardBoxComponentFooter>}
|
||||||
</>
|
</>
|
||||||
)
|
),
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,25 +1,23 @@
|
|||||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
import React, { Component, ErrorInfo, ReactNode } from "react";
|
||||||
import { mdiAlertCircle } from '@mdi/js';
|
import { mdiAlertCircle } from "@mdi/js";
|
||||||
import BaseIcon from './BaseIcon';
|
|
||||||
|
|
||||||
// Define the props and state interfaces
|
import BaseIcon from "./BaseIcon";
|
||||||
interface ErrorBoundaryProps {
|
|
||||||
|
type ErrorBoundaryProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
};
|
||||||
|
|
||||||
interface ErrorBoundaryState {
|
type ErrorBoundaryState = {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
errorInfo: ErrorInfo | null;
|
errorInfo: ErrorInfo | null;
|
||||||
showStack: boolean;
|
showStack: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Class-based ErrorBoundary Component
|
|
||||||
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
constructor(props: ErrorBoundaryProps) {
|
constructor(props: ErrorBoundaryProps) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
// Define state variables
|
|
||||||
this.state = {
|
this.state = {
|
||||||
hasError: false,
|
hasError: false,
|
||||||
error: null,
|
error: null,
|
||||||
@ -29,80 +27,18 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
||||||
// Update state so the next render will show the fallback UI
|
|
||||||
return {
|
return {
|
||||||
hasError: true,
|
hasError: true,
|
||||||
error: error,
|
error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
prevProps: Readonly<ErrorBoundaryProps>,
|
console.error("Error caught in boundary:", error, errorInfo);
|
||||||
prevState: Readonly<ErrorBoundaryState>,
|
|
||||||
snapshot?: any,
|
|
||||||
) {
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
console.log('componentDidUpdate');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async componentWillUnmount() {
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
console.log('componentWillUnmount');
|
|
||||||
const response = await fetch('/api/logError', {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
console.log('Error logs cleared:', data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
||||||
// Update state with error details (always needed for UI)
|
|
||||||
this.setState({
|
this.setState({
|
||||||
errorInfo: errorInfo,
|
errorInfo,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only perform logging in non-production environments
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
|
||||||
console.log('Error caught in boundary:', error, errorInfo);
|
|
||||||
|
|
||||||
// Function to log errors to the server
|
|
||||||
const logErrorToServer = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/logError', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
message: error.message,
|
|
||||||
stack: errorInfo.componentStack,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
console.log('Error logged:', data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to log error:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to fetch logged errors (optional)
|
|
||||||
const fetchLoggedErrors = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/logError');
|
|
||||||
const data = await response.json();
|
|
||||||
console.log('Fetched logs:', data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch logs:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await logErrorToServer();
|
|
||||||
await fetchLoggedErrors();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleStack = () => {
|
toggleStack = () => {
|
||||||
@ -120,98 +56,62 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
tryAgain = async () => {
|
render() {
|
||||||
// Only clear error logs in non-production environments
|
if (!this.state.hasError) {
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
return this.props.children;
|
||||||
try {
|
|
||||||
const response = await fetch('/api/logError', {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
console.log('Error logs cleared:', data);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to clear error logs:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always reset the error state (needed for UI recovery)
|
const { error, errorInfo, showStack } = this.state;
|
||||||
this.setState({ hasError: false });
|
const errorMessage = error?.message || "An unexpected error occurred";
|
||||||
};
|
const stackTrace =
|
||||||
|
errorInfo?.componentStack || error?.stack || "No stack trace available";
|
||||||
|
|
||||||
render() {
|
return (
|
||||||
if (this.state.hasError) {
|
<div className="flex min-h-screen items-center justify-center bg-slate-100 p-6">
|
||||||
// Extract error details
|
<div className="w-full max-w-xl rounded-2xl border border-rose-200 bg-white p-8 shadow-sm">
|
||||||
const { error, errorInfo, showStack } = this.state;
|
<div className="flex items-center gap-3">
|
||||||
const errorMessage = error?.message || 'An unexpected error occurred';
|
<div className="rounded-2xl bg-rose-50 p-3 text-rose-600">
|
||||||
const stackTrace =
|
<BaseIcon path={mdiAlertCircle} size={28} />
|
||||||
errorInfo?.componentStack || error?.stack || 'No stack trace available';
|
</div>
|
||||||
|
<div>
|
||||||
return (
|
<h1 className="text-xl font-semibold text-slate-950">
|
||||||
<div className='flex items-center justify-center min-h-screen bg-pavitra-300'>
|
Something went wrong
|
||||||
<div className='max-w-lg w-full p-8 bg-white rounded-lg shadow-sm'>
|
</h1>
|
||||||
<div className='flex flex-col items-center text-center space-y-6'>
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
<div className='p-3 bg-pavitra-500 rounded-full flex items-center justify-center'>
|
The app stopped on a visible frontend error.
|
||||||
<BaseIcon
|
</p>
|
||||||
path={mdiAlertCircle}
|
|
||||||
size={32}
|
|
||||||
className='text-pavitra-red'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='space-y-2'>
|
|
||||||
<h2 className='text-xl font-semibold text-pavitra-900'>
|
|
||||||
Something went wrong
|
|
||||||
</h2>
|
|
||||||
<p className='text-pavitra-800'>
|
|
||||||
We're sorry, but we encountered an unexpected error.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='w-full text-left p-4 bg-pavitra-400 rounded-md overflow-hidden'>
|
|
||||||
<p className='font-mono text-sm text-pavitra-red break-words'>
|
|
||||||
{errorMessage}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className='mt-4'>
|
|
||||||
<button
|
|
||||||
onClick={this.toggleStack}
|
|
||||||
className='text-xs text-pavitra-800 flex items-center gap-1'
|
|
||||||
>
|
|
||||||
<span>{showStack ? 'Hide' : 'Show'} stack trace</span>
|
|
||||||
<span className='text-xs'>{showStack ? '▲' : '▼'}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showStack && (
|
|
||||||
<pre className='mt-2 p-3 bg-pavitra-500 rounded text-xs font-mono text-pavitra-900 overflow-x-auto max-h-64'>
|
|
||||||
{stackTrace}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='space-y-4 w-full'>
|
|
||||||
<button
|
|
||||||
className='w-full py-2 px-4 bg-pavitra-blue hover:bg-pavitra-900 text-white rounded-md transition-colors'
|
|
||||||
onClick={this.tryAgain}
|
|
||||||
>
|
|
||||||
Try Again
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className='w-full py-2 px-4 border border-pavitra-600 text-pavitra-800 hover:bg-pavitra-400 rounded-md transition-colors'
|
|
||||||
onClick={this.resetError}
|
|
||||||
>
|
|
||||||
Go Back
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.props.children;
|
<div className="mt-6 rounded-xl bg-rose-50 p-4 font-mono text-sm text-rose-800">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showStack && (
|
||||||
|
<pre className="mt-4 max-h-72 overflow-auto rounded-xl bg-slate-950 p-4 text-xs text-slate-100">
|
||||||
|
{stackTrace}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={this.resetError}
|
||||||
|
className="rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={this.toggleStack}
|
||||||
|
className="rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
{showStack ? "Hide details" : "Show details"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,35 +1,31 @@
|
|||||||
import React, { ReactNode } from 'react'
|
import React, { ReactNode } from 'react';
|
||||||
import { containerMaxW } from '../config'
|
import { containerMaxW } from '../config';
|
||||||
import Logo from './Logo'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children?: ReactNode
|
children?: ReactNode;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function FooterBar({ children }: Props) {
|
export default function FooterBar({ children }: Props) {
|
||||||
const year = new Date().getFullYear()
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className={`py-2 px-6 ${containerMaxW}`}>
|
<footer className={`px-5 py-5 lg:px-8 ${containerMaxW}`}>
|
||||||
<div className="block md:flex items-center justify-between">
|
<div className='flex flex-col gap-2 border-t border-slate-200 pt-5 text-xs text-slate-500 md:flex-row md:items-center md:justify-between'>
|
||||||
<div className="text-center md:text-left mb-6 md:mb-0">
|
<div>
|
||||||
<b>
|
<span className='font-semibold text-slate-700'>
|
||||||
©{year},{` `}
|
© {year} B2B Distributor Portal.
|
||||||
<a href="https://flatlogic.com/" rel="noreferrer" target="_blank">
|
</span>{' '}
|
||||||
Flatlogic
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</b>
|
|
||||||
{` `}
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
<a
|
||||||
<div className="flex item-center md:py-2 gap-4">
|
href='https://flatlogic.com/'
|
||||||
<a href="https://flatlogic.com/" rel="noreferrer" target="_blank">
|
rel='noreferrer'
|
||||||
<Logo className="w-auto h-8 md:h-6 mx-auto" />
|
target='_blank'
|
||||||
</a>
|
className='font-semibold text-emerald-700'
|
||||||
</div>
|
>
|
||||||
|
Powered by Flatlogic
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,28 +1,26 @@
|
|||||||
import React, { ReactNode, useState, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react';
|
||||||
import { mdiClose, mdiDotsVertical } from '@mdi/js'
|
import { mdiClose, mdiDotsVertical } from '@mdi/js';
|
||||||
import { containerMaxW } from '../config'
|
import { containerMaxW } from '../config';
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon';
|
||||||
import NavBarItemPlain from './NavBarItemPlain'
|
import NavBarItemPlain from './NavBarItemPlain';
|
||||||
import NavBarMenuList from './NavBarMenuList'
|
import NavBarMenuList from './NavBarMenuList';
|
||||||
import { MenuNavBarItem } from '../interfaces'
|
import { MenuNavBarItem } from '../interfaces';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
menu: MenuNavBarItem[]
|
menu: MenuNavBarItem[];
|
||||||
className: string
|
className: string;
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function NavBar({ menu, className = '', children }: Props) {
|
export default function NavBar({ menu, className = '', children }: Props) {
|
||||||
const [isMenuNavBarActive, setIsMenuNavBarActive] = useState(false)
|
const [isMenuNavBarActive, setIsMenuNavBarActive] = useState(false);
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
const scrolled = window.scrollY > 0;
|
setIsScrolled(window.scrollY > 0);
|
||||||
setIsScrolled(scrolled);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('scroll', handleScroll);
|
window.addEventListener('scroll', handleScroll);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('scroll', handleScroll);
|
window.removeEventListener('scroll', handleScroll);
|
||||||
@ -30,28 +28,33 @@ export default function NavBar({ menu, className = '', children }: Props) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleMenuNavBarToggleClick = () => {
|
const handleMenuNavBarToggleClick = () => {
|
||||||
setIsMenuNavBarActive(!isMenuNavBarActive)
|
setIsMenuNavBarActive(!isMenuNavBarActive);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className={`${className} top-0 inset-x-0 fixed ${bgColor} h-14 z-30 transition-position w-screen lg:w-auto dark:bg-dark-800`}
|
className={`${className} fixed inset-x-0 top-0 z-30 h-16 w-screen bg-white/95 shadow-sm backdrop-blur transition-position lg:w-auto dark:bg-dark-800/95 ${
|
||||||
|
isScrolled ? 'border-b border-slate-200' : 'border-b border-transparent'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className={`flex lg:items-stretch ${containerMaxW} ${isScrolled && `border-b border-pavitra-400 dark:border-dark-700`}`}>
|
<div className={`flex h-16 lg:items-stretch ${containerMaxW}`}>
|
||||||
<div className="flex flex-1 items-stretch h-14">{children}</div>
|
<div className='flex h-16 flex-1 items-center'>{children}</div>
|
||||||
<div className="flex-none items-stretch flex h-14 lg:hidden">
|
<div className='flex h-16 flex-none items-stretch lg:hidden'>
|
||||||
<NavBarItemPlain onClick={handleMenuNavBarToggleClick}>
|
<NavBarItemPlain onClick={handleMenuNavBarToggleClick}>
|
||||||
<BaseIcon path={isMenuNavBarActive ? mdiClose : mdiDotsVertical} size="24" />
|
<BaseIcon
|
||||||
|
path={isMenuNavBarActive ? mdiClose : mdiDotsVertical}
|
||||||
|
size='24'
|
||||||
|
/>
|
||||||
</NavBarItemPlain>
|
</NavBarItemPlain>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
isMenuNavBarActive ? 'block' : 'hidden'
|
isMenuNavBarActive ? 'block' : 'hidden'
|
||||||
} flex items-center max-h-screen-menu overflow-y-auto lg:overflow-visible absolute w-screen top-14 left-0 ${bgColor} shadow-lg lg:w-auto lg:flex lg:static lg:shadow-none dark:bg-dark-800`}
|
} absolute left-0 top-16 max-h-screen-menu w-screen overflow-y-auto border-b border-slate-200 bg-white shadow-lg lg:static lg:flex lg:w-auto lg:items-center lg:overflow-visible lg:border-b-0 lg:shadow-none dark:bg-dark-800`}
|
||||||
>
|
>
|
||||||
<NavBarMenuList menu={menu} />
|
<NavBarMenuList menu={menu} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,69 +1,61 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import Link from 'next/link'
|
import Link from 'next/link';
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronDown, mdiChevronUp } from '@mdi/js';
|
||||||
import BaseDivider from './BaseDivider'
|
|
||||||
import BaseIcon from './BaseIcon'
|
|
||||||
import UserAvatarCurrentUser from './UserAvatarCurrentUser'
|
|
||||||
import NavBarMenuList from './NavBarMenuList'
|
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
|
||||||
import { MenuNavBarItem } from '../interfaces'
|
|
||||||
import { setDarkMode } from '../stores/styleSlice'
|
|
||||||
import { logoutUser } from '../stores/authSlice'
|
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import ClickOutside from "./ClickOutside";
|
import BaseDivider from './BaseDivider';
|
||||||
|
import BaseIcon from './BaseIcon';
|
||||||
|
import UserAvatarCurrentUser from './UserAvatarCurrentUser';
|
||||||
|
import NavBarMenuList from './NavBarMenuList';
|
||||||
|
import ClickOutside from './ClickOutside';
|
||||||
|
import { MenuNavBarItem } from '../interfaces';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
|
import { setDarkMode } from '../stores/styleSlice';
|
||||||
|
import { logoutUser } from '../stores/authSlice';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: MenuNavBarItem
|
item: MenuNavBarItem;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function NavBarItem({ item }: Props) {
|
export default function NavBarItem({ item }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const excludedRef = useRef(null);
|
const excludedRef = useRef(null);
|
||||||
|
|
||||||
const navBarItemLabelActiveColorStyle = useAppSelector(
|
|
||||||
(state) => state.style.navBarItemLabelActiveColorStyle
|
|
||||||
)
|
|
||||||
const navBarItemLabelStyle = useAppSelector((state) => state.style.navBarItemLabelStyle)
|
|
||||||
const navBarItemLabelHoverStyle = useAppSelector((state) => state.style.navBarItemLabelHoverStyle)
|
|
||||||
|
|
||||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||||
|
const userName = `${currentUser?.firstName ? currentUser?.firstName : ''} ${
|
||||||
const userName = `${currentUser?.firstName ? currentUser?.firstName : ""} ${currentUser?.lastName ? currentUser?.lastName : ""}`;
|
currentUser?.lastName ? currentUser?.lastName : ''
|
||||||
|
}`.trim();
|
||||||
const [isDropdownActive, setIsDropdownActive] = useState(false)
|
const [isDropdownActive, setIsDropdownActive] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => setIsDropdownActive(false);
|
return () => setIsDropdownActive(false);
|
||||||
}, [router.pathname]);
|
}, [router.pathname]);
|
||||||
|
|
||||||
const componentClass = [
|
const componentClass = [
|
||||||
'block lg:flex items-center relative cursor-pointer',
|
'block cursor-pointer rounded-xl text-sm font-semibold text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-950 lg:flex lg:items-center lg:mx-1',
|
||||||
isDropdownActive
|
item.menu ? 'lg:px-3 lg:py-2' : 'px-3 py-2',
|
||||||
? `${navBarItemLabelActiveColorStyle} dark:text-slate-400`
|
item.isDesktopNoLabel ? 'lg:w-10 lg:justify-center' : '',
|
||||||
: `${navBarItemLabelStyle} dark:text-white dark:hover:text-slate-400 ${navBarItemLabelHoverStyle}`,
|
].join(' ');
|
||||||
item.menu ? 'lg:py-2 lg:px-3' : 'py-2 px-3',
|
|
||||||
item.isDesktopNoLabel ? 'lg:w-16 lg:justify-center' : '',
|
|
||||||
].join(' ')
|
|
||||||
|
|
||||||
const itemLabel = item.isCurrentUser ? userName : item.label
|
const itemLabel = item.isCurrentUser
|
||||||
|
? userName || currentUser?.email || 'Account'
|
||||||
|
: item.label;
|
||||||
|
|
||||||
const handleMenuClick = () => {
|
const handleMenuClick = () => {
|
||||||
if (item.menu) {
|
if (item.menu) {
|
||||||
setIsDropdownActive(!isDropdownActive)
|
setIsDropdownActive(!isDropdownActive);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.isToggleLightDark) {
|
if (item.isToggleLightDark) {
|
||||||
dispatch(setDarkMode(null))
|
dispatch(setDarkMode(null));
|
||||||
}
|
}
|
||||||
|
|
||||||
if(item.isLogout) {
|
if (item.isLogout) {
|
||||||
dispatch(logoutUser())
|
dispatch(logoutUser());
|
||||||
router.push('/login')
|
router.push('/login');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const getItemId = (label) => {
|
const getItemId = (label) => {
|
||||||
switch (label) {
|
switch (label) {
|
||||||
case 'Light/Dark':
|
case 'Light/Dark':
|
||||||
return 'themeToggle';
|
return 'themeToggle';
|
||||||
@ -79,25 +71,27 @@ export default function NavBarItem({ item }: Props) {
|
|||||||
<div
|
<div
|
||||||
id={getItemId(itemLabel)}
|
id={getItemId(itemLabel)}
|
||||||
className={`flex items-center ${
|
className={`flex items-center ${
|
||||||
item.menu
|
item.menu ? 'bg-slate-50 p-3 lg:bg-transparent lg:p-0' : 'w-full'
|
||||||
? 'bg-gray-100 dark:bg-dark-800 lg:bg-transparent lg:dark:bg-transparent p-3 lg:p-0'
|
|
||||||
: 'w-full'
|
|
||||||
}`}
|
}`}
|
||||||
onClick={handleMenuClick}
|
onClick={handleMenuClick}
|
||||||
>
|
>
|
||||||
{item.icon && <BaseIcon path={item.icon} size={22} className="transition-colors" />}
|
{item.icon && (
|
||||||
|
<BaseIcon path={item.icon} size={20} className='transition-colors' />
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
className={`px-2 transition-colors w-40 grow ${
|
className={`px-2 transition-colors ${
|
||||||
item.isDesktopNoLabel && item.icon ? 'lg:hidden' : ''
|
item.isDesktopNoLabel && item.icon ? 'lg:hidden' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{itemLabel}
|
{itemLabel}
|
||||||
</span>
|
</span>
|
||||||
{item.isCurrentUser && <UserAvatarCurrentUser className="w-6 h-6 mr-3 inline-flex" />}
|
{item.isCurrentUser && (
|
||||||
|
<UserAvatarCurrentUser className='mr-2 inline-flex h-7 w-7' />
|
||||||
|
)}
|
||||||
{item.menu && (
|
{item.menu && (
|
||||||
<BaseIcon
|
<BaseIcon
|
||||||
path={isDropdownActive ? mdiChevronUp : mdiChevronDown}
|
path={isDropdownActive ? mdiChevronUp : mdiChevronDown}
|
||||||
className="hidden lg:inline-flex transition-colors"
|
className='hidden transition-colors lg:inline-flex'
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -105,18 +99,21 @@ export default function NavBarItem({ item }: Props) {
|
|||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
!isDropdownActive ? 'lg:hidden' : ''
|
!isDropdownActive ? 'lg:hidden' : ''
|
||||||
} text-sm border-b border-gray-100 lg:border lg:bg-white lg:absolute lg:top-full lg:left-0 lg:min-w-full lg:z-20 lg:rounded-lg lg:shadow-lg lg:dark:bg-dark-900 dark:border-dark-700`}
|
} border-b border-slate-100 text-sm lg:absolute lg:left-0 lg:top-full lg:z-20 lg:min-w-full lg:rounded-xl lg:border lg:border-slate-200 lg:bg-white lg:shadow-lg dark:border-dark-700 dark:bg-dark-900`}
|
||||||
>
|
>
|
||||||
<ClickOutside onClickOutside={() => setIsDropdownActive(false)} excludedElements={[excludedRef]}>
|
<ClickOutside
|
||||||
|
onClickOutside={() => setIsDropdownActive(false)}
|
||||||
|
excludedElements={[excludedRef]}
|
||||||
|
>
|
||||||
<NavBarMenuList menu={item.menu} />
|
<NavBarMenuList menu={item.menu} />
|
||||||
</ClickOutside>
|
</ClickOutside>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
|
|
||||||
if (item.isDivider) {
|
if (item.isDivider) {
|
||||||
return <BaseDivider navBar />
|
return <BaseDivider navBar />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.href) {
|
if (item.href) {
|
||||||
@ -124,8 +121,12 @@ export default function NavBarItem({ item }: Props) {
|
|||||||
<Link href={item.href} target={item.target} className={componentClass}>
|
<Link href={item.href} target={item.target} className={componentClass}>
|
||||||
{NavBarItemComponentContents}
|
{NavBarItemComponentContents}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className={componentClass} ref={excludedRef}>{NavBarItemComponentContents}</div>
|
return (
|
||||||
|
<div className={componentClass} ref={excludedRef}>
|
||||||
|
{NavBarItemComponentContents}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import React, { ReactNode } from 'react'
|
import React, { ReactNode } from 'react';
|
||||||
import { useAppSelector } from '../stores/hooks'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
display?: string
|
display?: string;
|
||||||
useMargin?: boolean
|
useMargin?: boolean;
|
||||||
children?: ReactNode
|
children?: ReactNode;
|
||||||
onClick?: (e: React.MouseEvent) => void
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function NavBarItemPlain({
|
export default function NavBarItemPlain({
|
||||||
display = 'flex',
|
display = 'flex',
|
||||||
@ -14,17 +13,14 @@ export default function NavBarItemPlain({
|
|||||||
onClick,
|
onClick,
|
||||||
children,
|
children,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const navBarItemLabelStyle = useAppSelector((state) => state.style.navBarItemLabelStyle)
|
const spacing = useMargin ? 'mx-3' : 'px-3';
|
||||||
const navBarItemLabelHoverStyle = useAppSelector((state) => state.style.navBarItemLabelHoverStyle)
|
|
||||||
|
|
||||||
const classBase = 'items-center cursor-pointer dark:text-white dark:hover:text-slate-400'
|
|
||||||
const classAddon = `${display} ${navBarItemLabelStyle} ${navBarItemLabelHoverStyle} ${
|
|
||||||
useMargin ? 'my-2 mx-3' : 'py-2 px-3'
|
|
||||||
}`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${classBase} ${classAddon}`} onClick={onClick}>
|
<div
|
||||||
|
className={`${display} ${spacing} h-10 cursor-pointer items-center rounded-xl text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-950 dark:text-white dark:hover:bg-dark-700 dark:hover:text-slate-100`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
|
|
||||||
const Search = () => {
|
const Search = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const focusRing = useAppSelector((state) => state.style.focusRingColor);
|
|
||||||
const corners = useAppSelector((state) => state.style.corners);
|
|
||||||
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
|
|
||||||
const validateSearch = (value) => {
|
const validateSearch = (value) => {
|
||||||
let error;
|
let error;
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@ -17,6 +14,7 @@ const Search = () => {
|
|||||||
}
|
}
|
||||||
return error;
|
return error;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
@ -31,20 +29,23 @@ const Search = () => {
|
|||||||
validateOnChange={false}
|
validateOnChange={false}
|
||||||
>
|
>
|
||||||
{({ errors, touched, values }) => (
|
{({ errors, touched, values }) => (
|
||||||
<Form style={{width: '300px'}} >
|
<Form className='relative w-[340px] max-w-[44vw]'>
|
||||||
<Field
|
<Field
|
||||||
id='search'
|
id='search'
|
||||||
name='search'
|
name='search'
|
||||||
validate={validateSearch}
|
validate={validateSearch}
|
||||||
placeholder='Search'
|
placeholder='Search accounts, orders, SKUs'
|
||||||
className={` ${corners} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-2 relative ml-2 w-full dark:placeholder-dark-600 ${focusRing} shadow-none`}
|
className='h-10 w-full rounded-xl border border-slate-200 bg-slate-50 px-4 text-sm text-slate-900 outline-none transition focus:border-emerald-500 focus:bg-white focus:ring-2 focus:ring-emerald-100 dark:border-dark-700 dark:bg-dark-900 dark:text-white dark:placeholder-dark-600'
|
||||||
/>
|
/>
|
||||||
{errors.search && touched.search && values.search.length < 2 ? (
|
{errors.search && touched.search && values.search.length < 2 ? (
|
||||||
<div className='text-red-500 text-sm ml-2 absolute'>{errors.search}</div>
|
<div className='absolute left-2 top-11 text-xs font-semibold text-red-500'>
|
||||||
|
{errors.search}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Search;
|
export default Search;
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
import React, { ReactNode } from 'react'
|
import React, { ReactNode } from 'react';
|
||||||
import { containerMaxW } from '../config'
|
import { containerMaxW } from '../config';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function SectionMain({ children }: Props) {
|
export default function SectionMain({ children }: Props) {
|
||||||
return <section className={`p-6 ${containerMaxW}`}>{children}</section>
|
return (
|
||||||
|
<section className={`px-5 py-6 lg:px-8 lg:py-7 ${containerMaxW}`}>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,29 +1,51 @@
|
|||||||
import { mdiCog } from '@mdi/js'
|
import { mdiCog } from '@mdi/js';
|
||||||
import React, { Children, ReactNode } from 'react'
|
import React, { Children, ReactNode } from 'react';
|
||||||
import BaseButton from './BaseButton'
|
import BaseButton from './BaseButton';
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon';
|
||||||
import IconRounded from './IconRounded'
|
|
||||||
import { humanize } from '../helpers/humanize';
|
import { humanize } from '../helpers/humanize';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
icon: string
|
icon: string;
|
||||||
title: string
|
title: string;
|
||||||
main?: boolean
|
main?: boolean;
|
||||||
children?: ReactNode
|
children?: ReactNode;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function SectionTitleLineWithButton({ icon, title, main = false, children }: Props) {
|
export default function SectionTitleLineWithButton({
|
||||||
const hasChildren = !!Children.count(children)
|
icon,
|
||||||
|
title,
|
||||||
|
main = false,
|
||||||
|
children,
|
||||||
|
}: Props) {
|
||||||
|
const hasChildren = !!Children.count(children);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`${main ? '' : 'pt-6'} mb-6 flex items-center justify-between`}>
|
<section
|
||||||
<div className="flex items-center justify-start">
|
className={`${
|
||||||
{icon && main && <IconRounded icon={icon} color="light" className="mr-3" bg />}
|
main ? '' : 'pt-6'
|
||||||
{icon && !main && <BaseIcon path={icon} className="mr-2" size="20" />}
|
} mb-6 flex flex-col gap-4 border-b border-slate-200 pb-5 sm:flex-row sm:items-center sm:justify-between`}
|
||||||
<h1 className={`leading-tight ${main ? 'text-3xl' : 'text-2xl'}`}>{humanize(title)}</h1>
|
>
|
||||||
|
<div className='flex items-center justify-start gap-3'>
|
||||||
|
{icon && (
|
||||||
|
<span className='inline-flex h-11 w-11 items-center justify-center rounded-xl border border-emerald-200 bg-emerald-50 text-emerald-700'>
|
||||||
|
<BaseIcon path={icon} size='22' />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700'>
|
||||||
|
Supplier operations
|
||||||
|
</p>
|
||||||
|
<h1
|
||||||
|
className={`mt-1 font-semibold leading-tight text-slate-950 dark:text-white ${
|
||||||
|
main ? 'text-3xl' : 'text-2xl'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{humanize(title)}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
{!hasChildren && <BaseButton icon={mdiCog} color="whiteDark" />}
|
{!hasChildren && <BaseButton icon={mdiCog} color='whiteDark' />}
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
frontend/src/css/.__table.css
Normal file
BIN
frontend/src/css/.__table.css
Normal file
Binary file not shown.
@ -8,8 +8,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
tr {
|
tr {
|
||||||
@apply max-w-full block relative border-b-4 border-gray-100
|
@apply max-w-full block relative border-b border-slate-200
|
||||||
lg:table-row lg:border-b-0 dark:border-slate-800;
|
lg:table-row dark:border-slate-800;
|
||||||
}
|
}
|
||||||
|
|
||||||
tr:last-child {
|
tr:last-child {
|
||||||
@ -17,11 +17,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
td:not(:first-child) {
|
td:not(:first-child) {
|
||||||
@apply lg:border-l lg:border-t-0 lg:border-r-0 lg:border-b-0 lg:border-gray-100 lg:dark:border-slate-700;
|
@apply lg:border-t-0 lg:border-r-0 lg:border-b-0 lg:border-slate-100 lg:dark:border-slate-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
@apply lg:text-left lg:p-3 border-b;
|
@apply lg:text-left lg:px-4 lg:py-3 border-b border-slate-200 bg-slate-50 text-xs font-semibold uppercase tracking-[0.14em] text-slate-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
th.sortable {
|
th.sortable {
|
||||||
@ -32,20 +32,20 @@
|
|||||||
transition: all 1s;
|
transition: all 1s;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
||||||
content: "↕";
|
content: '↕';
|
||||||
|
|
||||||
margin-left: 1rem;
|
margin-left: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
th.sortable.asc:hover:after {
|
th.sortable.asc:hover:after {
|
||||||
content: "↑";
|
content: '↑';
|
||||||
}
|
}
|
||||||
th.sortable.desc:hover:after {
|
th.sortable.desc:hover:after {
|
||||||
content: "↓";
|
content: '↓';
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
@apply flex justify-between text-right py-3 px-4 align-top border-b border-gray-100
|
@apply flex justify-between text-right py-3 px-4 align-top border-b border-slate-100
|
||||||
lg:table-cell lg:text-left lg:p-3 lg:align-middle lg:border-b-0 dark:border-slate-800 dark:text-white;
|
lg:table-cell lg:text-left lg:p-3 lg:align-middle lg:border-b-0 dark:border-slate-800 dark:text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,12 +53,13 @@
|
|||||||
@apply border-b-0;
|
@apply border-b-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr, tbody tr:nth-child(odd) {
|
tbody tr,
|
||||||
@apply lg:hover:bg-pavitra-300/70;
|
tbody tr:nth-child(odd) {
|
||||||
|
@apply bg-white lg:hover:bg-emerald-50/70;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr:nth-child(even) {
|
tbody tr:nth-child(even) {
|
||||||
@apply lg:bg-pavitra-300 dark:bg-pavitra-300/70;
|
@apply lg:bg-slate-50/60 dark:bg-dark-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
td:before {
|
td:before {
|
||||||
@ -67,44 +68,43 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
tbody tr td {
|
tbody tr td {
|
||||||
@apply text-sm font-normal text-pavitra-900 dark:text-white;
|
@apply text-sm font-normal text-slate-700 dark:text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.datagrid--table, .MuiDataGrid-root {
|
.datagrid--table,
|
||||||
@apply rounded border-none !important;
|
.MuiDataGrid-root {
|
||||||
|
@apply rounded-xl border-none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.datagrid--header {
|
.datagrid--header {
|
||||||
@apply uppercase !important;
|
@apply uppercase tracking-[0.12em] bg-slate-50 text-slate-500 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.datagrid--header,
|
.datagrid--header,
|
||||||
.datagrid--header .MuiIconButton-root,
|
.datagrid--header .MuiIconButton-root,
|
||||||
.datagrid--cell,
|
.datagrid--cell,
|
||||||
.datagrid--cell .MuiIconButton-root {
|
.datagrid--cell .MuiIconButton-root {
|
||||||
@apply dark:text-white;
|
@apply dark:text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.datagrid--cell .MuiDataGrid-booleanCell {
|
.datagrid--cell .MuiDataGrid-booleanCell {
|
||||||
@apply dark:text-white !important;
|
@apply dark:text-white !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.datagrid--cell .MuiIconButton-root:hover {
|
.datagrid--cell .MuiIconButton-root:hover {
|
||||||
@apply dark:text-white dark:bg-dark-700;
|
@apply dark:text-white dark:bg-dark-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.datagrid--row {
|
.datagrid--row {
|
||||||
@apply even:bg-gray-100 dark:even:bg-[#1B1D22] dark:odd:bg-dark-900 !important;
|
@apply even:bg-slate-50 hover:bg-emerald-50/70 dark:even:bg-[#1B1D22] dark:odd:bg-dark-900 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.datagrid--table .MuiTablePagination-root {
|
.datagrid--table .MuiTablePagination-root {
|
||||||
@apply dark:text-white;
|
@apply dark:text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.datagrid--table .MuiTablePagination-root .MuiButtonBase-root:disabled {
|
.datagrid--table .MuiTablePagination-root .MuiButtonBase-root:disabled {
|
||||||
@apply dark:text-dark-700;
|
@apply dark:text-dark-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.datagrid--table .MuiTablePagination-root .MuiButtonBase-root:hover {
|
.datagrid--table .MuiTablePagination-root .MuiButtonBase-root:hover {
|
||||||
@ -112,6 +112,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.MuiButton-colorInherit {
|
.MuiButton-colorInherit {
|
||||||
@apply text-blue-600 dark:text-dark-700 !important;
|
@apply text-emerald-700 dark:text-dark-700 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
frontend/src/layouts/._Authenticated.tsx
Normal file
BIN
frontend/src/layouts/._Authenticated.tsx
Normal file
Binary file not shown.
@ -1,114 +1,132 @@
|
|||||||
import React, { ReactNode, useEffect, useState } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js';
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside';
|
||||||
import menuNavBar from '../menuNavBar'
|
import menuNavBar from '../menuNavBar';
|
||||||
import BaseIcon from '../components/BaseIcon'
|
import BaseIcon from '../components/BaseIcon';
|
||||||
import NavBar from '../components/NavBar'
|
import NavBar from '../components/NavBar';
|
||||||
import NavBarItemPlain from '../components/NavBarItemPlain'
|
import NavBarItemPlain from '../components/NavBarItemPlain';
|
||||||
import AsideMenu from '../components/AsideMenu'
|
import AsideMenu from '../components/AsideMenu';
|
||||||
import FooterBar from '../components/FooterBar'
|
import FooterBar from '../components/FooterBar';
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
import Search from '../components/Search';
|
import Search from '../components/Search';
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router';
|
||||||
import {findMe, logoutUser} from "../stores/authSlice";
|
import { findMe, logoutUser } from '../stores/authSlice';
|
||||||
|
|
||||||
import {hasPermission} from "../helpers/userPermissions";
|
|
||||||
|
|
||||||
|
import { hasPermission } from '../helpers/userPermissions';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
|
|
||||||
permission?: string
|
permission?: string;
|
||||||
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export default function LayoutAuthenticated({
|
export default function LayoutAuthenticated({
|
||||||
children,
|
children,
|
||||||
|
|
||||||
permission
|
permission,
|
||||||
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const { token, currentUser } = useAppSelector((state) => state.auth)
|
const { token, currentUser } = useAppSelector((state) => state.auth);
|
||||||
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
|
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
|
||||||
let localToken
|
let localToken;
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
// Perform localStorage action
|
// Perform localStorage action
|
||||||
localToken = localStorage.getItem('token')
|
localToken = localStorage.getItem('token');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTokenValid = () => {
|
const isTokenValid = () => {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (!token) return;
|
if (!token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const date = new Date().getTime() / 1000;
|
const date = new Date().getTime() / 1000;
|
||||||
const data = jwt.decode(token);
|
const data = jwt.decode(token) as { exp?: number } | null;
|
||||||
if (!data) return;
|
if (!data) {
|
||||||
return date < data.exp;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !!data.exp && date < data.exp;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(findMe());
|
|
||||||
if (!isTokenValid()) {
|
if (!isTokenValid()) {
|
||||||
dispatch(logoutUser());
|
dispatch(logoutUser());
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [token, localToken]);
|
|
||||||
|
|
||||||
|
dispatch(findMe());
|
||||||
|
}, [token, localToken, router.pathname, router.asPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!permission || !currentUser) return;
|
if (!permission || !currentUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasPermission(currentUser, permission)) router.push('/error');
|
if (!hasPermission(currentUser, permission)) {
|
||||||
|
router.push('/error');
|
||||||
|
}
|
||||||
}, [currentUser, permission]);
|
}, [currentUser, permission]);
|
||||||
|
|
||||||
|
|
||||||
const darkMode = useAppSelector((state) => state.style.darkMode)
|
const isPermissionDenied =
|
||||||
|
!!permission && !!currentUser && !hasPermission(currentUser, permission);
|
||||||
|
|
||||||
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false)
|
const darkMode = useAppSelector((state) => state.style.darkMode);
|
||||||
const [isAsideLgActive, setIsAsideLgActive] = useState(false)
|
|
||||||
|
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false);
|
||||||
|
const [isAsideLgActive, setIsAsideLgActive] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleRouteChangeStart = () => {
|
const handleRouteChangeStart = () => {
|
||||||
setIsAsideMobileExpanded(false)
|
setIsAsideMobileExpanded(false);
|
||||||
setIsAsideLgActive(false)
|
setIsAsideLgActive(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
router.events.on('routeChangeStart', handleRouteChangeStart)
|
router.events.on('routeChangeStart', handleRouteChangeStart);
|
||||||
|
|
||||||
// If the component is unmounted, unsubscribe
|
// If the component is unmounted, unsubscribe
|
||||||
// from the event with the `off` method:
|
// from the event with the `off` method:
|
||||||
return () => {
|
return () => {
|
||||||
router.events.off('routeChangeStart', handleRouteChangeStart)
|
router.events.off('routeChangeStart', handleRouteChangeStart);
|
||||||
}
|
};
|
||||||
}, [router.events, dispatch])
|
}, [router.events, dispatch]);
|
||||||
|
|
||||||
|
const layoutAsidePadding = 'xl:pl-72';
|
||||||
const layoutAsidePadding = 'xl:pl-60'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
|
<div
|
||||||
|
className={`${
|
||||||
|
darkMode ? 'dark' : ''
|
||||||
|
} overflow-hidden lg:overflow-visible`}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={`${layoutAsidePadding} ${
|
className={`${layoutAsidePadding} ${
|
||||||
isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''
|
isAsideMobileExpanded ? 'ml-72 lg:ml-0' : ''
|
||||||
} pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
|
} pt-16 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
|
||||||
>
|
>
|
||||||
<NavBar
|
<NavBar
|
||||||
menu={menuNavBar}
|
menu={menuNavBar}
|
||||||
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''}`}
|
className={`${layoutAsidePadding} ${
|
||||||
|
isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<NavBarItemPlain
|
<NavBarItemPlain
|
||||||
display="flex lg:hidden"
|
display='flex lg:hidden'
|
||||||
onClick={() => setIsAsideMobileExpanded(!isAsideMobileExpanded)}
|
onClick={() => setIsAsideMobileExpanded(!isAsideMobileExpanded)}
|
||||||
>
|
>
|
||||||
<BaseIcon path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger} size="24" />
|
<BaseIcon
|
||||||
|
path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger}
|
||||||
|
size='24'
|
||||||
|
/>
|
||||||
</NavBarItemPlain>
|
</NavBarItemPlain>
|
||||||
<NavBarItemPlain
|
<NavBarItemPlain
|
||||||
display="hidden lg:flex xl:hidden"
|
display='hidden lg:flex xl:hidden'
|
||||||
onClick={() => setIsAsideLgActive(true)}
|
onClick={() => setIsAsideLgActive(true)}
|
||||||
>
|
>
|
||||||
<BaseIcon path={mdiMenu} size="24" />
|
<BaseIcon path={mdiMenu} size='24' />
|
||||||
</NavBarItemPlain>
|
</NavBarItemPlain>
|
||||||
<NavBarItemPlain useMargin>
|
<NavBarItemPlain useMargin>
|
||||||
<Search />
|
<Search />
|
||||||
@ -120,9 +138,11 @@ export default function LayoutAuthenticated({
|
|||||||
menu={menuAside}
|
menu={menuAside}
|
||||||
onAsideLgClose={() => setIsAsideLgActive(false)}
|
onAsideLgClose={() => setIsAsideLgActive(false)}
|
||||||
/>
|
/>
|
||||||
{children}
|
<main className='min-h-[calc(100vh-4rem)]'>
|
||||||
<FooterBar>Hand-crafted & Made with ❤️</FooterBar>
|
{!isPermissionDenied && children}
|
||||||
|
</main>
|
||||||
|
<FooterBar>Northstar Foodservice supplier operations</FooterBar>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
138
frontend/src/layouts/BuyerPortal.tsx
Normal file
138
frontend/src/layouts/BuyerPortal.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import {
|
||||||
|
mdiCartOutline,
|
||||||
|
mdiFileDocumentOutline,
|
||||||
|
mdiHistory,
|
||||||
|
mdiLogout,
|
||||||
|
mdiMapMarkerOutline,
|
||||||
|
mdiPackageVariantClosed,
|
||||||
|
} from "@mdi/js";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import React, { ReactNode, useEffect } from "react";
|
||||||
|
|
||||||
|
import BaseIcon from "../components/BaseIcon";
|
||||||
|
import { useAppDispatch, useAppSelector } from "../stores/hooks";
|
||||||
|
import { findMe, logoutUser } from "../stores/authSlice";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ label: "Catalog", href: "#catalog", icon: mdiCartOutline },
|
||||||
|
{ label: "PO draft", href: "#purchase-order", icon: mdiFileDocumentOutline },
|
||||||
|
{ label: "Orders", href: "#orders", icon: mdiHistory },
|
||||||
|
{ label: "Samples", href: "#samples", icon: mdiPackageVariantClosed },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isStoredTokenValid = (token: string | null) => {
|
||||||
|
if (!token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedToken = jwt.decode(token) as { exp?: number } | null;
|
||||||
|
if (!decodedToken?.exp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date().getTime() / 1000 < decodedToken.exp;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LayoutBuyerPortal({ children }: Props) {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const router = useRouter();
|
||||||
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!router.isReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!isStoredTokenValid(token)) {
|
||||||
|
dispatch(logoutUser());
|
||||||
|
const returnTo = encodeURIComponent(router.asPath || "/buyer-portal/");
|
||||||
|
router.replace(`/buyer-login?returnTo=${returnTo}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
dispatch(findMe());
|
||||||
|
}
|
||||||
|
}, [currentUser, dispatch, router.isReady, router.asPath]);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
dispatch(logoutUser());
|
||||||
|
router.push("/buyer-login?returnTo=%2Fbuyer-portal%2F");
|
||||||
|
};
|
||||||
|
|
||||||
|
const buyerName =
|
||||||
|
[currentUser?.firstName, currentUser?.lastName].filter(Boolean).join(" ") ||
|
||||||
|
currentUser?.email ||
|
||||||
|
"Purchasing team";
|
||||||
|
const buyerRole = currentUser?.app_role?.name || "Buyer account";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#f6f3ec] text-slate-950">
|
||||||
|
<header className="sticky top-0 z-40 border-b border-stone-200 bg-white/95 backdrop-blur">
|
||||||
|
<div className="mx-auto flex max-w-[1440px] flex-col gap-4 px-5 py-4 lg:flex-row lg:items-center lg:justify-between lg:px-8">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<a href="/" className="min-w-0">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-[0.24em] text-emerald-700">
|
||||||
|
Northstar Foodservice
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-lg font-semibold text-slate-950">
|
||||||
|
Buyer Portal
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-stone-200 text-slate-600 hover:border-emerald-200 hover:bg-emerald-50 hover:text-emerald-700 lg:hidden"
|
||||||
|
aria-label="Sign out"
|
||||||
|
>
|
||||||
|
<BaseIcon path={mdiLogout} size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex gap-2 overflow-x-auto pb-1 lg:pb-0">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<a
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className="inline-flex shrink-0 items-center gap-2 rounded-full border border-stone-200 bg-stone-50 px-4 py-2 text-sm font-semibold text-slate-700 hover:border-emerald-200 hover:bg-emerald-50 hover:text-emerald-800"
|
||||||
|
>
|
||||||
|
<BaseIcon path={item.icon} size={16} />
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="hidden items-center gap-3 lg:flex">
|
||||||
|
<div className="flex items-center gap-2 rounded-full border border-stone-200 bg-stone-50 px-4 py-2 text-sm font-semibold text-slate-700">
|
||||||
|
<BaseIcon path={mdiMapMarkerOutline} size={16} />
|
||||||
|
Harbor Table
|
||||||
|
</div>
|
||||||
|
<div className="max-w-[260px] rounded-full bg-slate-950 px-4 py-2 text-sm font-semibold text-white">
|
||||||
|
<span className="block truncate">{buyerName}</span>
|
||||||
|
<span className="block truncate text-[11px] font-semibold text-slate-300">
|
||||||
|
{buyerRole}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-stone-200 bg-white text-slate-600 hover:border-rose-200 hover:bg-rose-50 hover:text-rose-700"
|
||||||
|
aria-label="Sign out"
|
||||||
|
>
|
||||||
|
<BaseIcon path={mdiLogout} size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,211 +1,217 @@
|
|||||||
import * as icon from '@mdi/js';
|
import * as icon from '@mdi/js';
|
||||||
import { MenuAsideItem } from './interfaces'
|
import { MenuAsideItem } from './interfaces';
|
||||||
|
|
||||||
const menuAside: MenuAsideItem[] = [
|
const menuAside: MenuAsideItem[] = [
|
||||||
{
|
{
|
||||||
href: '/dashboard',
|
href: '/dashboard',
|
||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Dashboard',
|
label: 'Operations dashboard',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/buyer-portal',
|
href: '/buyer-portal',
|
||||||
label: 'Buyer Portal',
|
icon: icon.mdiCart,
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
label: 'Buyer portal',
|
||||||
// @ts-ignore
|
permissions: 'READ_BUYER_PORTAL',
|
||||||
icon: 'mdiCart' in icon ? icon['mdiCart' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_PRODUCTS'
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
href: '/users/users-list',
|
|
||||||
label: 'Users',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: icon.mdiAccountGroup ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_USERS'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/roles/roles-list',
|
label: 'Customer operations',
|
||||||
label: 'Roles',
|
icon: icon.mdiDomain,
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
permissions: [
|
||||||
// @ts-ignore
|
'READ_ACCOUNTS',
|
||||||
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
|
'READ_LOCATIONS',
|
||||||
permissions: 'READ_ROLES'
|
'READ_CONTACTS',
|
||||||
|
'READ_PRICE_LISTS',
|
||||||
|
'READ_ACCOUNT_PRICE_LISTS',
|
||||||
|
'READ_QUOTES',
|
||||||
|
'READ_ORDERS',
|
||||||
|
],
|
||||||
|
menu: [
|
||||||
|
{
|
||||||
|
href: '/accounts/accounts-list',
|
||||||
|
label: 'Accounts',
|
||||||
|
icon: icon.mdiDomain,
|
||||||
|
permissions: 'READ_ACCOUNTS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/locations/locations-list',
|
||||||
|
label: 'Ship-to locations',
|
||||||
|
icon: icon.mdiMapMarker,
|
||||||
|
permissions: 'READ_LOCATIONS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/contacts/contacts-list',
|
||||||
|
label: 'Contacts',
|
||||||
|
icon: icon.mdiAccountMultiple,
|
||||||
|
permissions: 'READ_CONTACTS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/price_lists/price_lists-list',
|
||||||
|
label: 'Price lists',
|
||||||
|
icon: icon.mdiCashMultiple,
|
||||||
|
permissions: 'READ_PRICE_LISTS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/account_price_lists/account_price_lists-list',
|
||||||
|
label: 'Account pricing',
|
||||||
|
icon: icon.mdiLinkVariant,
|
||||||
|
permissions: 'READ_ACCOUNT_PRICE_LISTS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/quotes/quotes-list',
|
||||||
|
label: 'Quotes',
|
||||||
|
icon: icon.mdiFileDocumentOutline,
|
||||||
|
permissions: 'READ_QUOTES',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/orders/orders-list',
|
||||||
|
label: 'Orders',
|
||||||
|
icon: icon.mdiClipboardTextOutline,
|
||||||
|
permissions: 'READ_ORDERS',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/permissions/permissions-list',
|
label: 'Catalog and inventory',
|
||||||
label: 'Permissions',
|
icon: icon.mdiPackageVariantClosed,
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
permissions: [
|
||||||
// @ts-ignore
|
'READ_PRODUCT_CATEGORIES',
|
||||||
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
|
'READ_PRODUCTS',
|
||||||
permissions: 'READ_PERMISSIONS'
|
'READ_INVENTORY_ITEMS',
|
||||||
|
'READ_SAMPLE_REQUESTS',
|
||||||
|
'READ_SAVED_LISTS',
|
||||||
|
],
|
||||||
|
menu: [
|
||||||
|
{
|
||||||
|
href: '/product_categories/product_categories-list',
|
||||||
|
label: 'Categories',
|
||||||
|
icon: icon.mdiTagMultiple,
|
||||||
|
permissions: 'READ_PRODUCT_CATEGORIES',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/products/products-list',
|
||||||
|
label: 'Products',
|
||||||
|
icon: icon.mdiPackageVariantClosed,
|
||||||
|
permissions: 'READ_PRODUCTS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/inventory_items/inventory_items-list',
|
||||||
|
label: 'Inventory',
|
||||||
|
icon: icon.mdiWarehouse,
|
||||||
|
permissions: 'READ_INVENTORY_ITEMS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/sample_requests/sample_requests-list',
|
||||||
|
label: 'Sample requests',
|
||||||
|
icon: icon.mdiPackageVariant,
|
||||||
|
permissions: 'READ_SAMPLE_REQUESTS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/saved_lists/saved_lists-list',
|
||||||
|
label: 'Saved order guides',
|
||||||
|
icon: icon.mdiBookmarkMultiple,
|
||||||
|
permissions: 'READ_SAVED_LISTS',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/accounts/accounts-list',
|
label: 'Fulfillment',
|
||||||
label: 'Accounts',
|
icon: icon.mdiTruckDelivery,
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
permissions: [
|
||||||
// @ts-ignore
|
'READ_CARTS',
|
||||||
icon: 'mdiDomain' in icon ? icon['mdiDomain' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
'READ_CART_ITEMS',
|
||||||
permissions: 'READ_ACCOUNTS'
|
'READ_ORDER_ITEMS',
|
||||||
|
'READ_SHIPMENTS',
|
||||||
|
'READ_QUOTE_ITEMS',
|
||||||
|
'READ_PRICE_LIST_ITEMS',
|
||||||
|
'READ_SAVED_LIST_ITEMS',
|
||||||
|
],
|
||||||
|
menu: [
|
||||||
|
{
|
||||||
|
href: '/shipments/shipments-list',
|
||||||
|
label: 'Shipments',
|
||||||
|
icon: icon.mdiTruckDelivery,
|
||||||
|
permissions: 'READ_SHIPMENTS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/order_items/order_items-list',
|
||||||
|
label: 'Order lines',
|
||||||
|
icon: icon.mdiClipboardList,
|
||||||
|
permissions: 'READ_ORDER_ITEMS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/carts/carts-list',
|
||||||
|
label: 'Carts',
|
||||||
|
icon: icon.mdiCart,
|
||||||
|
permissions: 'READ_CARTS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/cart_items/cart_items-list',
|
||||||
|
label: 'Cart lines',
|
||||||
|
icon: icon.mdiFormatListBulleted,
|
||||||
|
permissions: 'READ_CART_ITEMS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/quote_items/quote_items-list',
|
||||||
|
label: 'Quote lines',
|
||||||
|
icon: icon.mdiFileDocumentEditOutline,
|
||||||
|
permissions: 'READ_QUOTE_ITEMS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/price_list_items/price_list_items-list',
|
||||||
|
label: 'Price lines',
|
||||||
|
icon: icon.mdiCash,
|
||||||
|
permissions: 'READ_PRICE_LIST_ITEMS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/saved_list_items/saved_list_items-list',
|
||||||
|
label: 'Saved list lines',
|
||||||
|
icon: icon.mdiPlaylistPlus,
|
||||||
|
permissions: 'READ_SAVED_LIST_ITEMS',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/locations/locations-list',
|
label: 'System',
|
||||||
label: 'Locations',
|
icon: icon.mdiShieldAccountOutline,
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
permissions: [
|
||||||
// @ts-ignore
|
'READ_USERS',
|
||||||
icon: 'mdiMapMarker' in icon ? icon['mdiMapMarker' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
'READ_ROLES',
|
||||||
permissions: 'READ_LOCATIONS'
|
'READ_PERMISSIONS',
|
||||||
},
|
'READ_API_DOCS',
|
||||||
{
|
],
|
||||||
href: '/contacts/contacts-list',
|
menu: [
|
||||||
label: 'Contacts',
|
{
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
href: '/users/users-list',
|
||||||
// @ts-ignore
|
label: 'Users',
|
||||||
icon: 'mdiAccountMultiple' in icon ? icon['mdiAccountMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
icon: icon.mdiAccountGroup,
|
||||||
permissions: 'READ_CONTACTS'
|
permissions: 'READ_USERS',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/product_categories/product_categories-list',
|
href: '/roles/roles-list',
|
||||||
label: 'Product categories',
|
label: 'Roles',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
icon: icon.mdiShieldAccountVariantOutline,
|
||||||
// @ts-ignore
|
permissions: 'READ_ROLES',
|
||||||
icon: 'mdiTagMultiple' in icon ? icon['mdiTagMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
},
|
||||||
permissions: 'READ_PRODUCT_CATEGORIES'
|
{
|
||||||
},
|
href: '/permissions/permissions-list',
|
||||||
{
|
label: 'Permissions',
|
||||||
href: '/products/products-list',
|
icon: icon.mdiShieldAccountOutline,
|
||||||
label: 'Products',
|
permissions: 'READ_PERMISSIONS',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
},
|
||||||
// @ts-ignore
|
{
|
||||||
icon: 'mdiPackageVariantClosed' in icon ? icon['mdiPackageVariantClosed' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
href: '/api-docs',
|
||||||
permissions: 'READ_PRODUCTS'
|
target: '_blank',
|
||||||
},
|
label: 'API docs',
|
||||||
{
|
icon: icon.mdiFileCode,
|
||||||
href: '/inventory_items/inventory_items-list',
|
permissions: 'READ_API_DOCS',
|
||||||
label: 'Inventory items',
|
},
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
],
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiWarehouse' in icon ? icon['mdiWarehouse' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_INVENTORY_ITEMS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/price_lists/price_lists-list',
|
|
||||||
label: 'Price lists',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiCashMultiple' in icon ? icon['mdiCashMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_PRICE_LISTS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/price_list_items/price_list_items-list',
|
|
||||||
label: 'Price list items',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiCash' in icon ? icon['mdiCash' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_PRICE_LIST_ITEMS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/account_price_lists/account_price_lists-list',
|
|
||||||
label: 'Account price lists',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiLinkVariant' in icon ? icon['mdiLinkVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_ACCOUNT_PRICE_LISTS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/carts/carts-list',
|
|
||||||
label: 'Carts',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiCart' in icon ? icon['mdiCart' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_CARTS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/cart_items/cart_items-list',
|
|
||||||
label: 'Cart items',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiFormatListBulleted' in icon ? icon['mdiFormatListBulleted' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_CART_ITEMS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/orders/orders-list',
|
|
||||||
label: 'Orders',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiClipboardText' in icon ? icon['mdiClipboardText' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_ORDERS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/order_items/order_items-list',
|
|
||||||
label: 'Order items',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiClipboardList' in icon ? icon['mdiClipboardList' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_ORDER_ITEMS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/shipments/shipments-list',
|
|
||||||
label: 'Shipments',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiTruckDelivery' in icon ? icon['mdiTruckDelivery' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_SHIPMENTS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/quotes/quotes-list',
|
|
||||||
label: 'Quotes',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiFileDocumentOutline' in icon ? icon['mdiFileDocumentOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_QUOTES'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/quote_items/quote_items-list',
|
|
||||||
label: 'Quote items',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiFileDocumentEditOutline' in icon ? icon['mdiFileDocumentEditOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_QUOTE_ITEMS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/sample_requests/sample_requests-list',
|
|
||||||
label: 'Sample requests',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiPackageVariant' in icon ? icon['mdiPackageVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_SAMPLE_REQUESTS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/saved_lists/saved_lists-list',
|
|
||||||
label: 'Saved lists',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiBookmarkMultiple' in icon ? icon['mdiBookmarkMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_SAVED_LISTS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/saved_list_items/saved_list_items-list',
|
|
||||||
label: 'Saved list items',
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
icon: 'mdiPlaylistPlus' in icon ? icon['mdiPlaylistPlus' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
|
|
||||||
permissions: 'READ_SAVED_LIST_ITEMS'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/profile',
|
href: '/profile',
|
||||||
label: 'Profile',
|
label: 'Profile',
|
||||||
icon: icon.mdiAccountCircle,
|
icon: icon.mdiAccountCircle,
|
||||||
},
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default menuAside;
|
||||||
{
|
|
||||||
href: '/api-docs',
|
|
||||||
target: '_blank',
|
|
||||||
label: 'Swagger API',
|
|
||||||
icon: icon.mdiFileCode,
|
|
||||||
permissions: 'READ_API_DOCS'
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default menuAside
|
|
||||||
|
|||||||
BIN
frontend/src/pages/._dashboard.tsx
Normal file
BIN
frontend/src/pages/._dashboard.tsx
Normal file
Binary file not shown.
BIN
frontend/src/pages/._profile.tsx
Normal file
BIN
frontend/src/pages/._profile.tsx
Normal file
Binary file not shown.
279
frontend/src/pages/buyer-login.tsx
Normal file
279
frontend/src/pages/buyer-login.tsx
Normal file
@ -0,0 +1,279 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
import Head from "next/head";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
|
import LayoutGuest from "../layouts/Guest";
|
||||||
|
import { getPageTitle } from "../config";
|
||||||
|
|
||||||
|
const heroImage =
|
||||||
|
"https://images.pexels.com/photos/1126728/pexels-photo-1126728.jpeg?auto=compress&cs=tinysrgb&w=1800";
|
||||||
|
|
||||||
|
const sampleImage =
|
||||||
|
"https://images.pexels.com/photos/4198018/pexels-photo-4198018.jpeg?auto=compress&cs=tinysrgb&w=900";
|
||||||
|
|
||||||
|
const buyerCredentials = [
|
||||||
|
{
|
||||||
|
label: "Buyer admin",
|
||||||
|
role: "Customer Buyer Admin",
|
||||||
|
name: "Maria Alvarez",
|
||||||
|
email: "maria.alvarez@harbortable.com",
|
||||||
|
password: "a04a876c6d59",
|
||||||
|
note: "Purchasing lead with order, sample, quote, and location workflow access.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Buyer user",
|
||||||
|
role: "Customer Buyer",
|
||||||
|
name: "Owen Price",
|
||||||
|
email: "owen.price@harbortable.com",
|
||||||
|
password: "a04a876c6d59",
|
||||||
|
note: "Restaurant buyer focused on catalog, reorder history, and PO checkout.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function BuyerLoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState(buyerCredentials[0].email);
|
||||||
|
const [password, setPassword] = useState(buyerCredentials[0].password);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
const getReturnTo = () => {
|
||||||
|
const value = Array.isArray(router.query.returnTo)
|
||||||
|
? router.query.returnTo[0]
|
||||||
|
: router.query.returnTo;
|
||||||
|
|
||||||
|
if (value && value.startsWith("/")) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/buyer-portal/";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setErrorMessage("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: token } = await axios.post("/auth/signin/local", {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
const user = jwt.decode(token);
|
||||||
|
|
||||||
|
localStorage.setItem("token", token);
|
||||||
|
localStorage.setItem("user", JSON.stringify(user));
|
||||||
|
axios.defaults.headers.common.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
router.push(getReturnTo());
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Buyer login failed", error);
|
||||||
|
setErrorMessage(
|
||||||
|
error?.response?.data ||
|
||||||
|
"Unable to sign in with those buyer credentials."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const useCredentials = (credentials: typeof buyerCredentials[number]) => {
|
||||||
|
setEmail(credentials.email);
|
||||||
|
setPassword(credentials.password);
|
||||||
|
setErrorMessage("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle("Buyer sign in")}</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Buyer sign in for the Northstar Foodservice B2B supplier and distributor portal."
|
||||||
|
/>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<main className="min-h-screen bg-stone-50 text-slate-950">
|
||||||
|
<section className="grid min-h-screen lg:grid-cols-[minmax(0,1.1fr)_520px]">
|
||||||
|
<div className="relative hidden overflow-hidden lg:block">
|
||||||
|
<img
|
||||||
|
src={heroImage}
|
||||||
|
alt="Restaurant table prepared for foodservice buyers"
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-slate-950/80 via-slate-950/35 to-transparent" />
|
||||||
|
<div className="relative flex h-full flex-col justify-between p-12 text-white">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-200">
|
||||||
|
Northstar Foodservice
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-5 max-w-2xl text-5xl font-semibold leading-tight">
|
||||||
|
Contract ordering for restaurant buyers
|
||||||
|
</h1>
|
||||||
|
<p className="mt-5 max-w-xl text-base leading-8 text-stone-100">
|
||||||
|
Browse account pricing, request samples, build purchase
|
||||||
|
orders, and reorder from history without chasing PDF sheets.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid max-w-2xl gap-3 sm:grid-cols-3">
|
||||||
|
<div className="border-l-4 border-emerald-300 bg-slate-950/45 p-4 backdrop-blur">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-stone-300">
|
||||||
|
Price file
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 font-semibold">Northstar Contract</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-l-4 border-amber-300 bg-slate-950/45 p-4 backdrop-blur">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-stone-300">
|
||||||
|
Cutoff
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 font-semibold">Today 3 PM</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-l-4 border-sky-300 bg-slate-950/45 p-4 backdrop-blur">
|
||||||
|
<p className="text-xs uppercase tracking-[0.18em] text-stone-300">
|
||||||
|
Workflow
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 font-semibold">Catalog to PO</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center px-6 py-10">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
className="inline-flex text-sm font-semibold text-emerald-700"
|
||||||
|
>
|
||||||
|
Back to portal overview
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="mt-8 overflow-hidden border border-stone-200 bg-white shadow-sm">
|
||||||
|
<img
|
||||||
|
src={sampleImage}
|
||||||
|
alt="Sample-ready specialty ingredient"
|
||||||
|
className="h-44 w-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="p-6">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-emerald-700">
|
||||||
|
Buyer sign in
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-3 text-3xl font-semibold">
|
||||||
|
Open your order guide
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-sm leading-7 text-slate-600">
|
||||||
|
Sign in to see contract pricing, saved order behavior,
|
||||||
|
sample requests, and purchase-order checkout.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-3">
|
||||||
|
{buyerCredentials.map((credentials) => {
|
||||||
|
const isActive =
|
||||||
|
email === credentials.email &&
|
||||||
|
password === credentials.password;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={credentials.email}
|
||||||
|
type="button"
|
||||||
|
onClick={() => useCredentials(credentials)}
|
||||||
|
className={`rounded-2xl border p-4 text-left transition ${
|
||||||
|
isActive
|
||||||
|
? "border-emerald-300 bg-emerald-50"
|
||||||
|
: "border-stone-200 bg-stone-50 hover:border-emerald-200 hover:bg-emerald-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-slate-950">
|
||||||
|
{credentials.label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs font-semibold uppercase tracking-[0.16em] text-emerald-700">
|
||||||
|
{credentials.role}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-600 shadow-sm">
|
||||||
|
Use creds
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm font-semibold text-slate-900">
|
||||||
|
{credentials.name}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 break-all font-mono text-xs text-slate-600">
|
||||||
|
{credentials.email}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-mono text-xs text-slate-500">
|
||||||
|
{credentials.password}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-xs leading-5 text-slate-500">
|
||||||
|
{credentials.note}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="mt-5 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-800">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm font-semibold text-slate-900">
|
||||||
|
Email
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
className="mt-2 h-12 w-full rounded-xl border border-stone-300 bg-white px-4 text-slate-950 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-sm font-semibold text-slate-900">
|
||||||
|
Password
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
className="mt-2 h-12 w-full rounded-xl border border-stone-300 bg-white px-4 text-slate-950 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="h-12 w-full rounded-xl bg-emerald-500 px-5 text-sm font-bold text-slate-950 shadow-sm transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{isSubmitting
|
||||||
|
? "Signing in..."
|
||||||
|
: "Sign in to buyer portal"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-5 rounded-2xl bg-stone-50 p-4 text-sm leading-7 text-slate-600">
|
||||||
|
Demo workspace is prefilled for the generated project. A
|
||||||
|
production supplier would connect this page to buyer
|
||||||
|
accounts, SSO, or invited customer contacts.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
BuyerLoginPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -210,7 +210,7 @@ export default function HomePage() {
|
|||||||
Login
|
Login
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/buyer-portal"
|
href="/buyer-login?returnTo=%2Fbuyer-portal%2F"
|
||||||
className="rounded-full bg-slate-950 px-5 py-2 text-white shadow-sm"
|
className="rounded-full bg-slate-950 px-5 py-2 text-white shadow-sm"
|
||||||
>
|
>
|
||||||
Open portal
|
Open portal
|
||||||
@ -244,7 +244,7 @@ export default function HomePage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-8 flex flex-wrap gap-3">
|
<div className="mt-8 flex flex-wrap gap-3">
|
||||||
<a
|
<a
|
||||||
href="/buyer-portal"
|
href="/buyer-login?returnTo=%2Fbuyer-portal%2F"
|
||||||
className="rounded-full bg-emerald-400 px-6 py-3 text-sm font-bold text-slate-950 shadow-lg"
|
className="rounded-full bg-emerald-400 px-6 py-3 text-sm font-bold text-slate-950 shadow-lg"
|
||||||
>
|
>
|
||||||
Open buyer portal
|
Open buyer portal
|
||||||
@ -517,7 +517,7 @@ export default function HomePage() {
|
|||||||
availability before the order reaches the distributor team.
|
availability before the order reaches the distributor team.
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href="/buyer-portal"
|
href="/buyer-login?returnTo=%2Fbuyer-portal%2F"
|
||||||
className="mt-4 block rounded-xl bg-emerald-500 px-5 py-4 text-center text-sm font-bold text-slate-950"
|
className="mt-4 block rounded-xl bg-emerald-500 px-5 py-4 text-center text-sm font-bold text-slate-950"
|
||||||
>
|
>
|
||||||
Continue in real buyer portal
|
Continue in real buyer portal
|
||||||
@ -627,7 +627,7 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<a
|
<a
|
||||||
href="/buyer-portal"
|
href="/buyer-login?returnTo=%2Fbuyer-portal%2F"
|
||||||
className="rounded-xl bg-emerald-400 px-5 py-4 text-center text-sm font-bold text-slate-950"
|
className="rounded-xl bg-emerald-400 px-5 py-4 text-center text-sm font-bold text-slate-950"
|
||||||
>
|
>
|
||||||
Open buyer portal
|
Open buyer portal
|
||||||
|
|||||||
@ -1,273 +1,347 @@
|
|||||||
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import BaseButton from '../components/BaseButton';
|
import Link from 'next/link';
|
||||||
import CardBox from '../components/CardBox';
|
|
||||||
import BaseIcon from "../components/BaseIcon";
|
|
||||||
import { mdiInformation, mdiEye, mdiEyeOff } from '@mdi/js';
|
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
|
||||||
import { Field, Form, Formik } from 'formik';
|
|
||||||
import FormField from '../components/FormField';
|
|
||||||
import FormCheckRadio from '../components/FormCheckRadio';
|
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
import { toast, ToastContainer } from 'react-toastify';
|
||||||
|
|
||||||
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { findMe, loginUser, resetAction } from '../stores/authSlice';
|
import { findMe, loginUser, resetAction } from '../stores/authSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
import Link from 'next/link';
|
|
||||||
import {toast, ToastContainer} from "react-toastify";
|
const heroImage =
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'
|
'https://images.pexels.com/photos/1126728/pexels-photo-1126728.jpeg?auto=compress&cs=tinysrgb&w=1800';
|
||||||
|
|
||||||
|
const teamImage =
|
||||||
|
'https://images.pexels.com/photos/6169659/pexels-photo-6169659.jpeg?auto=compress&cs=tinysrgb&w=900';
|
||||||
|
|
||||||
|
const staffCredentials = [
|
||||||
|
{
|
||||||
|
label: 'Supplier admin',
|
||||||
|
role: 'Administrator',
|
||||||
|
name: 'Northstar Operations',
|
||||||
|
email: 'admin@flatlogic.com',
|
||||||
|
password: '3ab96dc1',
|
||||||
|
note: 'Full access to catalog, buyer accounts, price lists, orders, samples, and fulfillment workflows.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Demo user',
|
||||||
|
role: 'User',
|
||||||
|
name: 'Standard SaaS user',
|
||||||
|
email: 'client@hello.com',
|
||||||
|
password: 'a04a876c6d59',
|
||||||
|
note: 'Lightweight generated SaaS user for smoke checks outside the buyer-specific workspace.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
const [email, setEmail] = useState(staffCredentials[0].email);
|
||||||
const iconsColor = useAppSelector((state) => state.style.iconsColor);
|
const [password, setPassword] = useState(staffCredentials[0].password);
|
||||||
const notify = (type, msg) => toast(msg, { type });
|
const [remember, setRemember] = useState(true);
|
||||||
const [ illustrationImage, setIllustrationImage ] = useState({
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
src: undefined,
|
const {
|
||||||
photographer: undefined,
|
currentUser,
|
||||||
photographer_url: undefined,
|
isFetching,
|
||||||
})
|
errorMessage,
|
||||||
const [ illustrationVideo, setIllustrationVideo ] = useState({video_files: []})
|
token,
|
||||||
const [contentType, setContentType] = useState('video');
|
notify: notifyState,
|
||||||
const [contentPosition, setContentPosition] = useState('right');
|
} = useAppSelector((state) => state.auth);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const { currentUser, isFetching, errorMessage, token, notify:notifyState } = useAppSelector(
|
|
||||||
(state) => state.auth,
|
|
||||||
);
|
|
||||||
const [initialValues, setInitialValues] = React.useState({ email:'admin@flatlogic.com',
|
|
||||||
password: '3ab96dc1',
|
|
||||||
remember: true })
|
|
||||||
|
|
||||||
const title = 'B2B Distributor Portal'
|
const title = 'B2B Distributor Portal';
|
||||||
|
|
||||||
// Fetch Pexels image/video
|
|
||||||
useEffect( () => {
|
|
||||||
async function fetchData() {
|
|
||||||
const image = await getPexelsImage()
|
|
||||||
const video = await getPexelsVideo()
|
|
||||||
setIllustrationImage(image);
|
|
||||||
setIllustrationVideo(video);
|
|
||||||
}
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
// Fetch user data
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (token) {
|
||||||
dispatch(findMe());
|
dispatch(findMe());
|
||||||
}
|
}
|
||||||
}, [token, dispatch]);
|
}, [token, dispatch]);
|
||||||
// Redirect to dashboard if user is logged in
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUser?.id) {
|
if (currentUser?.id) {
|
||||||
|
if (
|
||||||
|
['Customer Buyer Admin', 'Customer Buyer'].includes(
|
||||||
|
currentUser.app_role?.name,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
router.push('/buyer-portal');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
}
|
}
|
||||||
}, [currentUser?.id, router]);
|
}, [currentUser?.id, router]);
|
||||||
// Show error message if there is one
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (errorMessage){
|
if (errorMessage) {
|
||||||
notify('error', errorMessage)
|
toast(errorMessage, { type: 'error' });
|
||||||
}
|
}
|
||||||
|
}, [errorMessage]);
|
||||||
|
|
||||||
}, [errorMessage])
|
|
||||||
// Show notification if there is one
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (notifyState?.showNotification) {
|
if (notifyState?.showNotification) {
|
||||||
notify('success', notifyState?.textNotification)
|
toast(notifyState?.textNotification, { type: 'success' });
|
||||||
dispatch(resetAction());
|
dispatch(resetAction());
|
||||||
}
|
}
|
||||||
}, [notifyState?.showNotification])
|
}, [notifyState?.showNotification, notifyState?.textNotification, dispatch]);
|
||||||
|
|
||||||
const togglePasswordVisibility = () => {
|
const applyCredentials = (credentials: (typeof staffCredentials)[number]) => {
|
||||||
setShowPassword(!showPassword);
|
setEmail(credentials.email);
|
||||||
|
setPassword(credentials.password);
|
||||||
|
setRemember(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (value) => {
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
const {remember, ...rest} = value
|
event.preventDefault();
|
||||||
await dispatch(loginUser(rest));
|
await dispatch(loginUser({ email, password }));
|
||||||
};
|
|
||||||
|
|
||||||
const setLogin = (target: HTMLElement) => {
|
|
||||||
setInitialValues(prev => ({
|
|
||||||
...prev,
|
|
||||||
email : target.innerText.trim(),
|
|
||||||
password: target.dataset.password ?? '',
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const imageBlock = (image) => (
|
|
||||||
<div className="hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `${image ? `url(${image.src?.original})` : 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}}>
|
|
||||||
<div className="flex justify-center w-full bg-blue-300/20">
|
|
||||||
<a className="text-[8px]" href={image?.photographer_url} target="_blank" rel="noreferrer">Photo
|
|
||||||
by {image?.photographer} on Pexels</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const videoBlock = (video) => {
|
|
||||||
if (video?.video_files?.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
|
||||||
<video
|
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
>
|
|
||||||
<source src={video.video_files[0]?.link} type='video/mp4'/>
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={video.user.url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Video by {video.user.name} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={contentPosition === 'background' ? {
|
<>
|
||||||
backgroundImage: `${
|
<Head>
|
||||||
illustrationImage
|
<title>{getPageTitle('Login')}</title>
|
||||||
? `url(${illustrationImage.src?.original})`
|
<meta
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
name='description'
|
||||||
}`,
|
content='Staff sign in for the Northstar Foodservice supplier and distributor portal.'
|
||||||
backgroundSize: 'cover',
|
/>
|
||||||
backgroundPosition: 'left center',
|
</Head>
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
} : {}}>
|
|
||||||
<Head>
|
|
||||||
<title>{getPageTitle('Login')}</title>
|
|
||||||
</Head>
|
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<main className='min-h-screen bg-stone-50 text-slate-950'>
|
||||||
<div className={`flex ${contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'} min-h-screen w-full`}>
|
<section className='grid min-h-screen lg:grid-cols-[minmax(0,1.1fr)_520px]'>
|
||||||
{contentType === 'image' && contentPosition !== 'background' ? imageBlock(illustrationImage) : null}
|
<div className='relative hidden overflow-hidden lg:block'>
|
||||||
{contentType === 'video' && contentPosition !== 'background' ? videoBlock(illustrationVideo) : null}
|
<img
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
src={heroImage}
|
||||||
|
alt='Fresh foodservice ingredients ready for distributor operations'
|
||||||
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
|
className='absolute inset-0 h-full w-full object-cover'
|
||||||
|
/>
|
||||||
<h2 className="text-4xl font-semibold my-4">{title}</h2>
|
<div className='absolute inset-0 bg-gradient-to-r from-slate-950/80 via-slate-950/35 to-transparent' />
|
||||||
|
<div className='relative flex h-full flex-col justify-between p-12 text-white'>
|
||||||
<div className='flex flex-row text-gray-500 justify-between'>
|
<div>
|
||||||
<div>
|
<p className='text-sm font-semibold uppercase tracking-[0.24em] text-emerald-200'>
|
||||||
|
Northstar Foodservice
|
||||||
<p className='mb-2'>Use{' '}
|
</p>
|
||||||
<code className={`cursor-pointer ${textColor} `}
|
<h1 className='mt-5 max-w-2xl text-5xl font-semibold leading-tight'>
|
||||||
data-password="3ab96dc1"
|
Staff workspace for distributor operations
|
||||||
onClick={(e) => setLogin(e.target)}>admin@flatlogic.com</code>{' / '}
|
</h1>
|
||||||
<code className={`${textColor}`}>3ab96dc1</code>{' / '}
|
<p className='mt-5 max-w-xl text-base leading-8 text-stone-100'>
|
||||||
to login as Admin</p>
|
Manage account-specific pricing, product catalogs, buyer
|
||||||
<p>Use <code
|
orders, sample requests, saved lists, and fulfillment handoff
|
||||||
className={`cursor-pointer ${textColor} `}
|
from one generated SaaS admin.
|
||||||
data-password="a04a876c6d59"
|
</p>
|
||||||
onClick={(e) => setLogin(e.target)}>client@hello.com</code>{' / '}
|
</div>
|
||||||
<code className={`${textColor}`}>a04a876c6d59</code>{' / '}
|
|
||||||
to login as User</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<BaseIcon
|
|
||||||
className={`${iconsColor}`}
|
|
||||||
w='w-16'
|
|
||||||
h='h-16'
|
|
||||||
size={48}
|
|
||||||
path={mdiInformation}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBox>
|
|
||||||
|
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
|
||||||
<Formik
|
|
||||||
initialValues={initialValues}
|
|
||||||
enableReinitialize
|
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
|
||||||
>
|
|
||||||
<Form>
|
|
||||||
<FormField
|
|
||||||
label='Login'
|
|
||||||
help='Please enter your login'>
|
|
||||||
<Field name='email' />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<div className='relative'>
|
<div className='grid max-w-2xl gap-3 sm:grid-cols-3'>
|
||||||
<FormField
|
<div className='border-l-4 border-emerald-300 bg-slate-950/45 p-4 backdrop-blur'>
|
||||||
label='Password'
|
<p className='text-xs uppercase tracking-[0.18em] text-stone-300'>
|
||||||
help='Please enter your password'>
|
Catalog
|
||||||
<Field name='password' type={showPassword ? 'text' : 'password'} />
|
</p>
|
||||||
</FormField>
|
<p className='mt-2 font-semibold'>Contract SKUs</p>
|
||||||
<div
|
</div>
|
||||||
className='absolute bottom-8 right-0 pr-3 flex items-center cursor-pointer'
|
<div className='border-l-4 border-amber-300 bg-slate-950/45 p-4 backdrop-blur'>
|
||||||
onClick={togglePasswordVisibility}
|
<p className='text-xs uppercase tracking-[0.18em] text-stone-300'>
|
||||||
>
|
Accounts
|
||||||
<BaseIcon
|
</p>
|
||||||
className='text-gray-500 hover:text-gray-700'
|
<p className='mt-2 font-semibold'>Buyer pricing</p>
|
||||||
size={20}
|
</div>
|
||||||
path={showPassword ? mdiEyeOff : mdiEye}
|
<div className='border-l-4 border-sky-300 bg-slate-950/45 p-4 backdrop-blur'>
|
||||||
/>
|
<p className='text-xs uppercase tracking-[0.18em] text-stone-300'>
|
||||||
</div>
|
Workflow
|
||||||
</div>
|
</p>
|
||||||
|
<p className='mt-2 font-semibold'>Orders to dock</p>
|
||||||
<div className={'flex justify-between'}>
|
</div>
|
||||||
<FormCheckRadio type='checkbox' label='Remember'>
|
</div>
|
||||||
<Field type='checkbox' name='remember' />
|
|
||||||
</FormCheckRadio>
|
|
||||||
|
|
||||||
<Link className={`${textColor} text-blue-600`} href={'/forgot'}>
|
|
||||||
Forgot password?
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BaseDivider />
|
|
||||||
|
|
||||||
<BaseButtons>
|
|
||||||
<BaseButton
|
|
||||||
className={'w-full'}
|
|
||||||
type='submit'
|
|
||||||
label={isFetching ? 'Loading...' : 'Login'}
|
|
||||||
color='info'
|
|
||||||
disabled={isFetching}
|
|
||||||
/>
|
|
||||||
</BaseButtons>
|
|
||||||
<br />
|
|
||||||
<p className={'text-center'}>
|
|
||||||
Don’t have an account yet?{' '}
|
|
||||||
<Link className={`${textColor}`} href={'/register'}>
|
|
||||||
New Account
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</Form>
|
|
||||||
</Formik>
|
|
||||||
</CardBox>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionFullScreen>
|
|
||||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
<div className='flex items-center justify-center px-6 py-10'>
|
||||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. © All rights reserved</p>
|
<div className='w-full max-w-md'>
|
||||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
<div className='flex items-center justify-between gap-4'>
|
||||||
Privacy Policy
|
<Link
|
||||||
</Link>
|
href='/'
|
||||||
</div>
|
className='inline-flex text-sm font-semibold text-emerald-700'
|
||||||
<ToastContainer />
|
>
|
||||||
</div>
|
Back to portal overview
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href='/buyer-login/?returnTo=%2Fbuyer-portal%2F'
|
||||||
|
className='text-sm font-semibold text-slate-500 hover:text-emerald-700'
|
||||||
|
>
|
||||||
|
Buyer login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-8 overflow-hidden border border-stone-200 bg-white shadow-sm'>
|
||||||
|
<img
|
||||||
|
src={teamImage}
|
||||||
|
alt='Foodservice operations team preparing restaurant orders'
|
||||||
|
className='h-36 w-full object-cover sm:h-44'
|
||||||
|
/>
|
||||||
|
<div className='p-5 sm:p-6'>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-emerald-700'>
|
||||||
|
Staff sign in
|
||||||
|
</p>
|
||||||
|
<h2 className='mt-3 text-2xl font-semibold sm:text-3xl'>
|
||||||
|
Open the supplier admin
|
||||||
|
</h2>
|
||||||
|
<p className='mt-3 text-sm leading-6 text-slate-600 sm:leading-7'>
|
||||||
|
Sign in to manage the operational side of the B2B
|
||||||
|
distributor portal: products, customer accounts, contract
|
||||||
|
pricing, orders, quotes, and sample workflows.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className='mt-4 grid gap-2 sm:mt-5 sm:gap-3'>
|
||||||
|
{staffCredentials.map((credentials) => {
|
||||||
|
const isActive =
|
||||||
|
email === credentials.email &&
|
||||||
|
password === credentials.password;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={credentials.email}
|
||||||
|
type='button'
|
||||||
|
onClick={() => applyCredentials(credentials)}
|
||||||
|
className={`rounded-2xl border p-3 text-left transition sm:p-4 ${
|
||||||
|
isActive
|
||||||
|
? 'border-emerald-300 bg-emerald-50'
|
||||||
|
: 'border-stone-200 bg-stone-50 hover:border-emerald-200 hover:bg-emerald-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className='flex items-start justify-between gap-3'>
|
||||||
|
<div>
|
||||||
|
<p className='text-sm font-bold text-slate-950'>
|
||||||
|
{credentials.label}
|
||||||
|
</p>
|
||||||
|
<p className='mt-1 text-xs font-semibold uppercase tracking-[0.16em] text-emerald-700'>
|
||||||
|
{credentials.role}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className='rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-600 shadow-sm'>
|
||||||
|
Use creds
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className='mt-3 text-sm font-semibold text-slate-900'>
|
||||||
|
{credentials.name}
|
||||||
|
</p>
|
||||||
|
<p className='mt-1 break-all font-mono text-xs text-slate-600'>
|
||||||
|
{credentials.email}
|
||||||
|
</p>
|
||||||
|
<p className='mt-1 font-mono text-xs text-slate-500'>
|
||||||
|
{credentials.password}
|
||||||
|
</p>
|
||||||
|
<p className='mt-3 hidden text-xs leading-5 text-slate-500 sm:block'>
|
||||||
|
{credentials.note}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<div className='mt-5 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-800'>
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className='mt-6 space-y-4' onSubmit={handleSubmit}>
|
||||||
|
<label className='block'>
|
||||||
|
<span className='text-sm font-semibold text-slate-900'>
|
||||||
|
Email
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
name='email'
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
type='email'
|
||||||
|
autoComplete='email'
|
||||||
|
className='mt-2 h-12 w-full rounded-xl border border-stone-300 bg-white px-4 text-slate-950 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className='block'>
|
||||||
|
<span className='text-sm font-semibold text-slate-900'>
|
||||||
|
Password
|
||||||
|
</span>
|
||||||
|
<div className='mt-2 flex h-12 overflow-hidden rounded-xl border border-stone-300 bg-white focus-within:border-emerald-500 focus-within:ring-2 focus-within:ring-emerald-100'>
|
||||||
|
<input
|
||||||
|
name='password'
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
autoComplete='current-password'
|
||||||
|
className='min-w-0 flex-1 border-0 bg-transparent px-4 text-slate-950 outline-none'
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setShowPassword((value) => !value)}
|
||||||
|
className='px-4 text-sm font-semibold text-slate-500 hover:text-emerald-700'
|
||||||
|
>
|
||||||
|
{showPassword ? 'Hide' : 'Show'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between gap-4 text-sm'>
|
||||||
|
<label className='inline-flex items-center gap-2 font-semibold text-slate-600'>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
checked={remember}
|
||||||
|
onChange={(event) =>
|
||||||
|
setRemember(event.target.checked)
|
||||||
|
}
|
||||||
|
className='h-4 w-4 rounded border-stone-300 text-emerald-600 focus:ring-emerald-500'
|
||||||
|
/>
|
||||||
|
Remember
|
||||||
|
</label>
|
||||||
|
<Link
|
||||||
|
href='/forgot'
|
||||||
|
className='font-semibold text-emerald-700'
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type='submit'
|
||||||
|
disabled={isFetching}
|
||||||
|
className='h-12 w-full rounded-xl bg-emerald-500 px-5 text-sm font-bold text-slate-950 shadow-sm transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-70'
|
||||||
|
>
|
||||||
|
{isFetching ? 'Signing in...' : 'Sign in to admin'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className='mt-5 rounded-2xl bg-stone-50 p-4 text-sm leading-7 text-slate-600'>
|
||||||
|
Need buyer access instead? Use the dedicated buyer login for
|
||||||
|
customer-side catalog, reorder, sample, and PO workflows.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className='mt-5 text-center text-sm text-slate-500'>
|
||||||
|
Do not have an account yet?{' '}
|
||||||
|
<Link
|
||||||
|
className='font-semibold text-emerald-700'
|
||||||
|
href='/register'
|
||||||
|
>
|
||||||
|
New Account
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-6 flex justify-center gap-4 text-xs text-slate-500'>
|
||||||
|
<span>© 2026 {title}</span>
|
||||||
|
<Link
|
||||||
|
href='/privacy-policy/'
|
||||||
|
className='hover:text-emerald-700'
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<ToastContainer />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,180 +1,429 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {
|
import {
|
||||||
mdiChartTimelineVariant,
|
mdiAccountCircle,
|
||||||
mdiUpload,
|
mdiAccountTie,
|
||||||
|
mdiChartTimelineVariant,
|
||||||
|
mdiCheckCircleOutline,
|
||||||
|
mdiEmailOutline,
|
||||||
|
mdiLockReset,
|
||||||
|
mdiPhoneOutline,
|
||||||
|
mdiShieldAccountOutline,
|
||||||
|
mdiStoreCogOutline,
|
||||||
|
mdiUpload,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
|
||||||
import { ToastContainer, toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import DatePicker from 'react-datepicker';
|
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
|
||||||
|
|
||||||
|
import BaseButton from '../components/BaseButton';
|
||||||
|
import BaseButtons from '../components/BaseButtons';
|
||||||
|
import BaseIcon from '../components/BaseIcon';
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
|
import FormField from '../components/FormField';
|
||||||
|
import FormImagePicker from '../components/FormImagePicker';
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
import SectionMain from '../components/SectionMain';
|
import SectionMain from '../components/SectionMain';
|
||||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||||
import { getPageTitle } from '../config';
|
|
||||||
|
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import FormField from '../components/FormField';
|
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import BaseButton from '../components/BaseButton';
|
|
||||||
import FormCheckRadio from '../components/FormCheckRadio';
|
|
||||||
import FormCheckRadioGroup from '../components/FormCheckRadioGroup';
|
|
||||||
import FormImagePicker from '../components/FormImagePicker';
|
|
||||||
import { SwitchField } from '../components/SwitchField';
|
|
||||||
import { SelectField } from '../components/SelectField';
|
import { SelectField } from '../components/SelectField';
|
||||||
|
import { SwitchField } from '../components/SwitchField';
|
||||||
import { update, fetch } from '../stores/users/usersSlice';
|
import { findMe } from '../stores/authSlice';
|
||||||
|
import { getPageTitle } from '../config';
|
||||||
|
import { update } from '../stores/users/usersSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import {findMe} from "../stores/authSlice";
|
|
||||||
|
|
||||||
const EditUsers = () => {
|
const initVals = {
|
||||||
const { currentUser, isFetching, token } = useAppSelector(
|
firstName: '',
|
||||||
(state) => state.auth,
|
lastName: '',
|
||||||
);
|
phoneNumber: '',
|
||||||
const router = useRouter();
|
email: '',
|
||||||
const dispatch = useAppDispatch();
|
app_role: '',
|
||||||
const notify = (type, msg) => toast(msg, { type });
|
disabled: false,
|
||||||
const initVals = {
|
avatar: [],
|
||||||
firstName: '',
|
password: '',
|
||||||
lastName: '',
|
|
||||||
phoneNumber: '',
|
|
||||||
email: '',
|
|
||||||
app_role: '',
|
|
||||||
disabled: false,
|
|
||||||
avatar: [],
|
|
||||||
password: ''
|
|
||||||
};
|
|
||||||
const [initialValues, setInitialValues] = useState(initVals);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentUser?.id && typeof currentUser === 'object') {
|
|
||||||
const newInitialVal = { ...initVals };
|
|
||||||
|
|
||||||
Object.keys(initVals).forEach(
|
|
||||||
(el) => (newInitialVal[el] = currentUser[el]),
|
|
||||||
);
|
|
||||||
|
|
||||||
setInitialValues(newInitialVal);
|
|
||||||
}
|
|
||||||
}, [currentUser]);
|
|
||||||
|
|
||||||
const handleSubmit = async (data) => {
|
|
||||||
await dispatch(update({ id: currentUser.id, data }));
|
|
||||||
await dispatch(findMe());
|
|
||||||
await router.push('/users/users-list');
|
|
||||||
notify('success', 'Profile was updated!');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Head>
|
|
||||||
<title>{getPageTitle('Edit profile')}</title>
|
|
||||||
</Head>
|
|
||||||
<SectionMain>
|
|
||||||
<SectionTitleLineWithButton
|
|
||||||
icon={mdiChartTimelineVariant}
|
|
||||||
title='Edit profile'
|
|
||||||
main
|
|
||||||
>
|
|
||||||
{''}
|
|
||||||
</SectionTitleLineWithButton>
|
|
||||||
<CardBox>
|
|
||||||
{currentUser?.avatar[0]?.publicUrl && <div className={'grid grid-cols-6 gap-4 mb-4'}>
|
|
||||||
<div className="col-span-1 w-80 h-80 overflow-hidden border-2 rounded-full inline-flex items-center justify-center mb-8">
|
|
||||||
<img className="w-80 h-80 max-w-full max-h-full object-cover object-center" src={`${currentUser?.avatar[0]?.publicUrl}`} alt="Avatar" />
|
|
||||||
</div>
|
|
||||||
</div>}
|
|
||||||
<Formik
|
|
||||||
enableReinitialize
|
|
||||||
initialValues={initialValues}
|
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
|
||||||
>
|
|
||||||
<Form>
|
|
||||||
<FormField>
|
|
||||||
<Field
|
|
||||||
label='Avatar'
|
|
||||||
color='info'
|
|
||||||
icon={mdiUpload}
|
|
||||||
path={'users/avatar'}
|
|
||||||
name='avatar'
|
|
||||||
id='avatar'
|
|
||||||
schema={{
|
|
||||||
size: undefined,
|
|
||||||
formats: undefined,
|
|
||||||
}}
|
|
||||||
component={FormImagePicker}
|
|
||||||
></Field>
|
|
||||||
</FormField>
|
|
||||||
<FormField label='First Name'>
|
|
||||||
<Field name='firstName' placeholder='First Name' />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label='Last Name'>
|
|
||||||
<Field name='lastName' placeholder='Last Name' />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label='Phone Number'>
|
|
||||||
<Field name='phoneNumber' placeholder='Phone Number' />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label='E-Mail'>
|
|
||||||
<Field name='email' placeholder='E-Mail' disabled />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label='App Role' labelFor='app_role'>
|
|
||||||
<Field
|
|
||||||
name='app_role'
|
|
||||||
id='app_role'
|
|
||||||
component={SelectField}
|
|
||||||
options={initialValues.app_role}
|
|
||||||
itemRef={'roles'}
|
|
||||||
showField={'name'}
|
|
||||||
></Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label='Disabled' labelFor='disabled'>
|
|
||||||
<Field
|
|
||||||
name='disabled'
|
|
||||||
id='disabled'
|
|
||||||
component={SwitchField}
|
|
||||||
></Field>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="Password"
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
name="password"
|
|
||||||
placeholder="password"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<BaseDivider />
|
|
||||||
|
|
||||||
<BaseButtons>
|
|
||||||
<BaseButton type='submit' color='info' label='Submit' />
|
|
||||||
<BaseButton type='reset' color='info' outline label='Reset' />
|
|
||||||
<BaseButton
|
|
||||||
type='reset'
|
|
||||||
color='danger'
|
|
||||||
outline
|
|
||||||
label='Cancel'
|
|
||||||
onClick={() => router.push('/users/users-list')}
|
|
||||||
/>
|
|
||||||
</BaseButtons>
|
|
||||||
</Form>
|
|
||||||
</Formik>
|
|
||||||
</CardBox>
|
|
||||||
</SectionMain>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
EditUsers.getLayout = function getLayout(page: ReactElement) {
|
const fieldClass =
|
||||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
'h-12 rounded-xl border border-slate-300 bg-white px-4 text-slate-950 outline-none focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100';
|
||||||
|
|
||||||
|
const Profile = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { currentUser, isFetching } = useAppSelector((state) => state.auth);
|
||||||
|
const [initialValues, setInitialValues] = useState(initVals);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentUser?.id && typeof currentUser === 'object') {
|
||||||
|
const nextInitialValues = { ...initVals };
|
||||||
|
|
||||||
|
Object.keys(initVals).forEach((fieldName) => {
|
||||||
|
nextInitialValues[fieldName] =
|
||||||
|
currentUser[fieldName] ?? initVals[fieldName];
|
||||||
|
});
|
||||||
|
|
||||||
|
setInitialValues(nextInitialValues);
|
||||||
|
}
|
||||||
|
}, [currentUser]);
|
||||||
|
|
||||||
|
const avatarUrl = currentUser?.avatar?.[0]?.publicUrl;
|
||||||
|
const roleName = currentUser?.app_role?.name || 'Role pending';
|
||||||
|
const fullName = useMemo(() => {
|
||||||
|
const name = [initialValues.firstName, initialValues.lastName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return name || currentUser?.email || 'Workspace user';
|
||||||
|
}, [currentUser?.email, initialValues.firstName, initialValues.lastName]);
|
||||||
|
|
||||||
|
const initials = useMemo(() => {
|
||||||
|
return fullName
|
||||||
|
.split(' ')
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase();
|
||||||
|
}, [fullName]);
|
||||||
|
|
||||||
|
async function handleSubmit(data) {
|
||||||
|
if (!currentUser?.id) {
|
||||||
|
throw new Error('Cannot update profile without current user id');
|
||||||
|
}
|
||||||
|
|
||||||
|
await dispatch(update({ id: currentUser.id, data }));
|
||||||
|
await dispatch(findMe());
|
||||||
|
toast('Profile was updated!', { type: 'success' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>{getPageTitle('Profile')}</title>
|
||||||
|
</Head>
|
||||||
|
<SectionMain>
|
||||||
|
<SectionTitleLineWithButton
|
||||||
|
icon={mdiChartTimelineVariant}
|
||||||
|
title='Profile'
|
||||||
|
main
|
||||||
|
>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
<BaseButton
|
||||||
|
href='/dashboard'
|
||||||
|
color='info'
|
||||||
|
label='Operations dashboard'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
|
<div className='grid gap-6 xl:grid-cols-[420px_minmax(0,1fr)]'>
|
||||||
|
<div className='space-y-5'>
|
||||||
|
<CardBox cardBoxClassName='p-0'>
|
||||||
|
<div className='bg-slate-950 p-6 text-white'>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-emerald-300'>
|
||||||
|
Northstar identity
|
||||||
|
</p>
|
||||||
|
<p className='mt-3 text-sm leading-6 text-slate-300'>
|
||||||
|
Profile details for the supplier operations workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='p-6'>
|
||||||
|
<div className='flex items-start gap-4'>
|
||||||
|
<div className='inline-flex h-24 w-24 flex-none items-center justify-center overflow-hidden rounded-2xl border border-slate-200 bg-slate-100 text-3xl font-semibold text-slate-700 shadow-sm'>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={`${fullName} avatar`}
|
||||||
|
className='h-full w-full object-cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
initials || 'NS'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='min-w-0 pt-1'>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-emerald-700'>
|
||||||
|
Signed-in user
|
||||||
|
</p>
|
||||||
|
<h2 className='mt-2 break-words text-2xl font-semibold text-slate-950'>
|
||||||
|
{fullName}
|
||||||
|
</h2>
|
||||||
|
<p className='mt-2 break-all text-sm text-slate-500'>
|
||||||
|
{currentUser?.email || 'Email pending'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-6 grid gap-3'>
|
||||||
|
<div className='rounded-xl border border-slate-200 bg-slate-50 p-4'>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<span className='inline-flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-50 text-emerald-700'>
|
||||||
|
<BaseIcon path={mdiShieldAccountOutline} size={22} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.16em] text-slate-400'>
|
||||||
|
Access role
|
||||||
|
</p>
|
||||||
|
<p className='mt-1 font-semibold text-slate-950'>
|
||||||
|
{roleName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='rounded-xl border border-slate-200 bg-slate-50 p-4'>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<span className='inline-flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-50 text-emerald-700'>
|
||||||
|
<BaseIcon path={mdiCheckCircleOutline} size={22} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.16em] text-slate-400'>
|
||||||
|
Account status
|
||||||
|
</p>
|
||||||
|
<p className='mt-1 font-semibold text-slate-950'>
|
||||||
|
{initialValues.disabled ? 'Disabled' : 'Active'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<CardBox>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700'>
|
||||||
|
Workspace context
|
||||||
|
</p>
|
||||||
|
<div className='mt-5 space-y-4'>
|
||||||
|
<div className='flex gap-3'>
|
||||||
|
<span className='inline-flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-slate-50 text-emerald-700 shadow-sm'>
|
||||||
|
<BaseIcon path={mdiStoreCogOutline} size={22} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className='font-semibold text-slate-950'>
|
||||||
|
Supplier operations
|
||||||
|
</p>
|
||||||
|
<p className='mt-1 text-sm leading-6 text-slate-500'>
|
||||||
|
This profile controls identity and access inside the
|
||||||
|
catalog, pricing, orders, and fulfillment admin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex gap-3'>
|
||||||
|
<span className='inline-flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-slate-50 text-emerald-700 shadow-sm'>
|
||||||
|
<BaseIcon path={mdiAccountTie} size={22} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className='font-semibold text-slate-950'>
|
||||||
|
Buyer-facing responsibility
|
||||||
|
</p>
|
||||||
|
<p className='mt-1 text-sm leading-6 text-slate-500'>
|
||||||
|
Keep name, phone, and avatar current so buyer teams can
|
||||||
|
recognize account owners in the portal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Formik
|
||||||
|
enableReinitialize
|
||||||
|
initialValues={initialValues}
|
||||||
|
onSubmit={(values) => handleSubmit(values)}
|
||||||
|
>
|
||||||
|
<Form className='space-y-6'>
|
||||||
|
<CardBox>
|
||||||
|
<div className='mb-6 flex flex-col gap-3 border-b border-slate-100 pb-5 md:flex-row md:items-start md:justify-between'>
|
||||||
|
<div>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700'>
|
||||||
|
Profile details
|
||||||
|
</p>
|
||||||
|
<h3 className='mt-2 text-2xl font-semibold text-slate-950'>
|
||||||
|
Contact identity
|
||||||
|
</h3>
|
||||||
|
<p className='mt-2 max-w-2xl text-sm leading-6 text-slate-500'>
|
||||||
|
Update the information that appears in the supplier admin
|
||||||
|
and buyer-support workflows.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600'>
|
||||||
|
<span className='font-semibold text-slate-950'>Role:</span>{' '}
|
||||||
|
{roleName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid gap-6 lg:grid-cols-[minmax(0,1fr)_220px]'>
|
||||||
|
<div className='grid gap-x-4 md:grid-cols-2'>
|
||||||
|
<FormField label='First name'>
|
||||||
|
<Field
|
||||||
|
name='firstName'
|
||||||
|
placeholder='First name'
|
||||||
|
className={fieldClass}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label='Last name'>
|
||||||
|
<Field
|
||||||
|
name='lastName'
|
||||||
|
placeholder='Last name'
|
||||||
|
className={fieldClass}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label='Phone number' icons={[mdiPhoneOutline]}>
|
||||||
|
<Field
|
||||||
|
name='phoneNumber'
|
||||||
|
placeholder='Phone number'
|
||||||
|
className={fieldClass}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className='md:col-span-2'>
|
||||||
|
<FormField label='Email' icons={[mdiEmailOutline]}>
|
||||||
|
<Field
|
||||||
|
name='email'
|
||||||
|
placeholder='Email'
|
||||||
|
disabled
|
||||||
|
className={fieldClass}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='rounded-2xl border border-slate-200 bg-slate-50 p-4'>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-slate-400'>
|
||||||
|
Avatar
|
||||||
|
</p>
|
||||||
|
<div className='mt-4 flex items-center gap-4 lg:block'>
|
||||||
|
<div className='inline-flex h-20 w-20 items-center justify-center overflow-hidden rounded-2xl bg-white text-2xl font-semibold text-slate-700 shadow-sm lg:h-28 lg:w-28'>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={`${fullName} avatar preview`}
|
||||||
|
className='h-full w-full object-cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
initials || 'NS'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='min-w-0 flex-1 lg:mt-4'>
|
||||||
|
<Field
|
||||||
|
label='Upload avatar'
|
||||||
|
color='info'
|
||||||
|
icon={mdiUpload}
|
||||||
|
path='users/avatar'
|
||||||
|
name='avatar'
|
||||||
|
id='avatar'
|
||||||
|
schema={{
|
||||||
|
size: undefined,
|
||||||
|
formats: undefined,
|
||||||
|
}}
|
||||||
|
component={FormImagePicker}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<CardBox>
|
||||||
|
<div className='mb-6 border-b border-slate-100 pb-5'>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700'>
|
||||||
|
Access and security
|
||||||
|
</p>
|
||||||
|
<h3 className='mt-2 text-2xl font-semibold text-slate-950'>
|
||||||
|
Role, account state, and password
|
||||||
|
</h3>
|
||||||
|
<p className='mt-2 max-w-2xl text-sm leading-6 text-slate-500'>
|
||||||
|
Keep operational access intentional. These fields affect how
|
||||||
|
the user moves through buyer, catalog, order, and system
|
||||||
|
modules.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid gap-x-4 md:grid-cols-2'>
|
||||||
|
<FormField label='App role' labelFor='app_role'>
|
||||||
|
<Field
|
||||||
|
name='app_role'
|
||||||
|
id='app_role'
|
||||||
|
component={SelectField}
|
||||||
|
options={initialValues.app_role}
|
||||||
|
itemRef='roles'
|
||||||
|
showField='name'
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div className='rounded-xl border border-slate-200 bg-slate-50 p-4'>
|
||||||
|
<FormField label='Disabled' labelFor='disabled'>
|
||||||
|
<Field
|
||||||
|
name='disabled'
|
||||||
|
id='disabled'
|
||||||
|
component={SwitchField}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<p className='text-sm leading-6 text-slate-500'>
|
||||||
|
Disabling an account should be reserved for offboarding,
|
||||||
|
access review, or compromised credentials.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='md:col-span-2'>
|
||||||
|
<FormField label='New password' icons={[mdiLockReset]}>
|
||||||
|
<Field
|
||||||
|
name='password'
|
||||||
|
type='password'
|
||||||
|
placeholder='Optional new password'
|
||||||
|
className={fieldClass}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
|
||||||
|
<CardBox>
|
||||||
|
<div className='flex flex-col gap-4 md:flex-row md:items-center md:justify-between'>
|
||||||
|
<div className='flex items-start gap-3'>
|
||||||
|
<span className='inline-flex h-11 w-11 flex-none items-center justify-center rounded-xl bg-emerald-50 text-emerald-700'>
|
||||||
|
<BaseIcon path={mdiAccountCircle} size={24} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className='font-semibold text-slate-950'>
|
||||||
|
Save profile changes
|
||||||
|
</p>
|
||||||
|
<p className='mt-1 text-sm leading-6 text-slate-500'>
|
||||||
|
Changes are applied to this account and refreshed in the
|
||||||
|
current session.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BaseButtons>
|
||||||
|
<BaseButton
|
||||||
|
type='submit'
|
||||||
|
color='info'
|
||||||
|
label={isFetching ? 'Saving...' : 'Save profile'}
|
||||||
|
disabled={isFetching}
|
||||||
|
/>
|
||||||
|
<BaseButton
|
||||||
|
type='reset'
|
||||||
|
color='info'
|
||||||
|
outline
|
||||||
|
label='Reset'
|
||||||
|
/>
|
||||||
|
</BaseButtons>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
</Form>
|
||||||
|
</Formik>
|
||||||
|
</div>
|
||||||
|
</SectionMain>
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default EditUsers;
|
Profile.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Profile;
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
interface StyleObject {
|
interface StyleObject {
|
||||||
aside: string
|
aside: string;
|
||||||
asideScrollbars: string
|
asideScrollbars: string;
|
||||||
asideBrand: string
|
asideBrand: string;
|
||||||
asideMenuItem: string
|
asideMenuItem: string;
|
||||||
asideMenuItemActive: string
|
asideMenuItemActive: string;
|
||||||
asideMenuDropdown: string
|
asideMenuDropdown: string;
|
||||||
navBarItemLabel: string
|
navBarItemLabel: string;
|
||||||
navBarItemLabelHover: string
|
navBarItemLabelHover: string;
|
||||||
navBarItemLabelActiveColor: string
|
navBarItemLabelActiveColor: string;
|
||||||
overlay: string
|
overlay: string;
|
||||||
activeLinkColor: string;
|
activeLinkColor: string;
|
||||||
bgLayoutColor: string;
|
bgLayoutColor: string;
|
||||||
iconsColor: string;
|
iconsColor: string;
|
||||||
@ -25,34 +25,32 @@ interface StyleObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const white: StyleObject = {
|
export const white: StyleObject = {
|
||||||
aside: 'bg-white dark:text-white',
|
aside: 'bg-white text-slate-900 border-r border-slate-200 dark:text-white',
|
||||||
asideScrollbars: 'aside-scrollbars-light',
|
asideScrollbars: 'aside-scrollbars-light',
|
||||||
asideBrand: '',
|
asideBrand: 'border-b border-slate-200',
|
||||||
asideMenuItem: 'text-gray-700 hover:bg-gray-100/70 dark:text-dark-500 dark:hover:text-white dark:hover:bg-dark-800',
|
asideMenuItem:
|
||||||
asideMenuItemActive: 'font-bold text-black dark:text-white',
|
'text-slate-600 hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:text-white dark:hover:bg-dark-800',
|
||||||
asideMenuDropdown: 'bg-gray-100/75',
|
asideMenuItemActive: 'font-semibold text-slate-950 dark:text-white',
|
||||||
navBarItemLabel: 'text-blue-600',
|
asideMenuDropdown: 'bg-slate-50/80',
|
||||||
navBarItemLabelHover: 'hover:text-black',
|
navBarItemLabel: 'text-slate-600',
|
||||||
navBarItemLabelActiveColor: 'text-black',
|
navBarItemLabelHover: 'hover:text-slate-950',
|
||||||
|
navBarItemLabelActiveColor: 'text-slate-950',
|
||||||
overlay: 'from-white via-gray-100 to-white',
|
overlay: 'from-white via-gray-100 to-white',
|
||||||
activeLinkColor: 'bg-gray-100/70',
|
activeLinkColor: 'bg-emerald-50 ring-1 ring-emerald-200',
|
||||||
bgLayoutColor: 'bg-gray-50',
|
bgLayoutColor: 'bg-slate-50',
|
||||||
iconsColor: 'text-blue-500',
|
iconsColor: 'text-emerald-700',
|
||||||
cardsColor: 'bg-white',
|
cardsColor: 'bg-white',
|
||||||
focusRingColor: 'focus:ring focus:ring-blue-600 focus:border-blue-600 focus:outline-none border-gray-300 dark:focus:ring-blue-600 dark:focus:border-blue-600',
|
focusRingColor:
|
||||||
corners: 'rounded',
|
'focus:ring focus:ring-emerald-100 focus:border-emerald-500 focus:outline-none border-slate-300 dark:focus:ring-emerald-800 dark:focus:border-emerald-500',
|
||||||
cardsStyle: 'bg-white border border-pavitra-400',
|
corners: 'rounded-xl',
|
||||||
linkColor: 'text-blue-600',
|
cardsStyle: 'bg-white border border-slate-200 shadow-sm',
|
||||||
websiteHeder: 'border-b border-gray-200',
|
linkColor: 'text-emerald-700',
|
||||||
borders: 'border-gray-200',
|
websiteHeder: 'border-b border-slate-200',
|
||||||
shadow: '',
|
borders: 'border-slate-200',
|
||||||
|
shadow: 'shadow-sm',
|
||||||
websiteSectionStyle: '',
|
websiteSectionStyle: '',
|
||||||
textSecondary: 'text-gray-500',
|
textSecondary: 'text-slate-500',
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const dataGridStyles = {
|
export const dataGridStyles = {
|
||||||
'& .MuiDataGrid-cell': {
|
'& .MuiDataGrid-cell': {
|
||||||
@ -81,27 +79,28 @@ export const dataGridStyles = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const basic: StyleObject = {
|
export const basic: StyleObject = {
|
||||||
aside: 'bg-gray-800',
|
aside: 'bg-gray-800',
|
||||||
asideScrollbars: 'aside-scrollbars-gray',
|
asideScrollbars: 'aside-scrollbars-gray',
|
||||||
asideBrand: 'bg-gray-900 text-white',
|
asideBrand: 'bg-gray-900 text-white',
|
||||||
asideMenuItem: 'text-gray-300 hover:text-white',
|
asideMenuItem: 'text-gray-300 hover:text-white',
|
||||||
asideMenuItemActive: 'font-bold text-white',
|
asideMenuItemActive: 'font-bold text-white',
|
||||||
asideMenuDropdown: 'bg-gray-700/50',
|
asideMenuDropdown: 'bg-gray-700/50',
|
||||||
navBarItemLabel: 'text-black',
|
navBarItemLabel: 'text-black',
|
||||||
navBarItemLabelHover: 'hover:text-blue-500',
|
navBarItemLabelHover: 'hover:text-blue-500',
|
||||||
navBarItemLabelActiveColor: 'text-blue-600',
|
navBarItemLabelActiveColor: 'text-blue-600',
|
||||||
overlay: 'from-gray-700 via-gray-900 to-gray-700',
|
overlay: 'from-gray-700 via-gray-900 to-gray-700',
|
||||||
activeLinkColor: 'bg-gray-100/70',
|
activeLinkColor: 'bg-gray-100/70',
|
||||||
bgLayoutColor: 'bg-gray-50',
|
bgLayoutColor: 'bg-gray-50',
|
||||||
iconsColor: 'text-blue-500',
|
iconsColor: 'text-blue-500',
|
||||||
cardsColor: 'bg-white',
|
cardsColor: 'bg-white',
|
||||||
focusRingColor: 'focus:ring focus:ring-blue-600 focus:border-blue-600 focus:outline-none dark:focus:ring-blue-600 border-gray-300 dark:focus:border-blue-600',
|
focusRingColor:
|
||||||
corners: 'rounded',
|
'focus:ring focus:ring-blue-600 focus:border-blue-600 focus:outline-none dark:focus:ring-blue-600 border-gray-300 dark:focus:border-blue-600',
|
||||||
cardsStyle: 'bg-white border border-pavitra-400',
|
corners: 'rounded',
|
||||||
linkColor: 'text-black',
|
cardsStyle: 'bg-white border border-pavitra-400',
|
||||||
websiteHeder: '',
|
linkColor: 'text-black',
|
||||||
borders: '',
|
websiteHeder: '',
|
||||||
shadow: '',
|
borders: '',
|
||||||
websiteSectionStyle: '',
|
shadow: '',
|
||||||
textSecondary: '',
|
websiteSectionStyle: '',
|
||||||
}
|
textSecondary: '',
|
||||||
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user