3
This commit is contained in:
parent
e8ed8d174b
commit
b8133ffe9f
@ -78,7 +78,7 @@ const CardAccounts = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Accountname</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>Account name</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.account_name }
|
{ item.account_name }
|
||||||
@ -90,7 +90,7 @@ const CardAccounts = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Accounttype</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>Account type</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.account_type }
|
{ item.account_type }
|
||||||
@ -102,7 +102,7 @@ const CardAccounts = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Accountnumber</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>Account number</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.account_number }
|
{ item.account_number }
|
||||||
@ -114,7 +114,7 @@ const CardAccounts = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Taxexemptnumber</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>Tax exempt number</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.tax_exempt_number }
|
{ item.tax_exempt_number }
|
||||||
@ -126,7 +126,7 @@ const CardAccounts = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Taxexempt</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>Tax exempt</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ dataFormatter.booleanFormatter(item.is_tax_exempt) }
|
{ dataFormatter.booleanFormatter(item.is_tax_exempt) }
|
||||||
@ -138,7 +138,7 @@ const CardAccounts = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Creditstatus</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>Credit status</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.credit_status }
|
{ item.credit_status }
|
||||||
@ -150,7 +150,7 @@ const CardAccounts = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Creditlimit</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>Credit limit</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.credit_limit }
|
{ item.credit_limit }
|
||||||
@ -162,7 +162,7 @@ const CardAccounts = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Defaultpricelist</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>Default price list</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ dataFormatter.price_listsOneListFormatter(item.default_price_list) }
|
{ dataFormatter.price_listsOneListFormatter(item.default_price_list) }
|
||||||
@ -174,7 +174,7 @@ const CardAccounts = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Assignedsalesrep</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>Assigned sales rep</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ dataFormatter.usersOneListFormatter(item.assigned_sales_rep) }
|
{ dataFormatter.usersOneListFormatter(item.assigned_sales_rep) }
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'account_name',
|
field: 'account_name',
|
||||||
headerName: 'Accountname',
|
headerName: 'Account name',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -58,7 +58,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'account_type',
|
field: 'account_type',
|
||||||
headerName: 'Accounttype',
|
headerName: 'Account type',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -73,7 +73,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'account_number',
|
field: 'account_number',
|
||||||
headerName: 'Accountnumber',
|
headerName: 'Account number',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -88,7 +88,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'tax_exempt_number',
|
field: 'tax_exempt_number',
|
||||||
headerName: 'Taxexemptnumber',
|
headerName: 'Tax exempt number',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -103,7 +103,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'is_tax_exempt',
|
field: 'is_tax_exempt',
|
||||||
headerName: 'Taxexempt',
|
headerName: 'Tax exempt',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -119,7 +119,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'credit_status',
|
field: 'credit_status',
|
||||||
headerName: 'Creditstatus',
|
headerName: 'Credit status',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -134,7 +134,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'credit_limit',
|
field: 'credit_limit',
|
||||||
headerName: 'Creditlimit',
|
headerName: 'Credit limit',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -150,7 +150,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'default_price_list',
|
field: 'default_price_list',
|
||||||
headerName: 'Defaultpricelist',
|
headerName: 'Default price list',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -172,7 +172,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'assigned_sales_rep',
|
field: 'assigned_sales_rep',
|
||||||
headerName: 'Assignedsalesrep',
|
headerName: 'Assigned sales rep',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ 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 { hasPermission } from '../helpers/userPermissions';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
menu: MenuAsideItem[];
|
menu: MenuAsideItem[];
|
||||||
@ -21,6 +22,23 @@ export default function AsideMenuLayer({
|
|||||||
(state) => state.style.asideScrollbarsStyle,
|
(state) => state.style.asideScrollbarsStyle,
|
||||||
);
|
);
|
||||||
const darkMode = useAppSelector((state) => state.style.darkMode);
|
const darkMode = useAppSelector((state) => state.style.darkMode);
|
||||||
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
const canUseBuyerPortal = hasPermission(currentUser, 'READ_BUYER_PORTAL');
|
||||||
|
const canReadBuyerTeamQueue = hasPermission(
|
||||||
|
currentUser,
|
||||||
|
'READ_BUYER_TEAM_QUEUE',
|
||||||
|
);
|
||||||
|
const hasSupplierAccess = hasPermission(currentUser, [
|
||||||
|
'READ_ACCOUNTS',
|
||||||
|
'READ_PRODUCTS',
|
||||||
|
'READ_ORDERS',
|
||||||
|
'READ_INVENTORY_ITEMS',
|
||||||
|
]);
|
||||||
|
const workspaceLabel = canUseBuyerPortal && !hasSupplierAccess
|
||||||
|
? canReadBuyerTeamQueue
|
||||||
|
? 'Buyer admin queue'
|
||||||
|
: 'Buyer ordering workspace'
|
||||||
|
: 'Supplier operations';
|
||||||
|
|
||||||
const handleAsideLgCloseClick = (e: React.MouseEvent) => {
|
const handleAsideLgCloseClick = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -70,7 +88,7 @@ export default function AsideMenuLayer({
|
|||||||
Workspace
|
Workspace
|
||||||
</p>
|
</p>
|
||||||
<p className='mt-1 text-sm font-semibold text-slate-700 dark:text-slate-200'>
|
<p className='mt-1 text-sm font-semibold text-slate-700 dark:text-slate-200'>
|
||||||
Contract catalog + orders
|
{workspaceLabel}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -78,7 +78,7 @@ const CardOrders = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Ordernumber</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>Order number</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.order_number }
|
{ item.order_number }
|
||||||
@ -126,7 +126,7 @@ const CardOrders = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>POnumber</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>PO number</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.po_number }
|
{ item.po_number }
|
||||||
@ -138,7 +138,7 @@ const CardOrders = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Orderedat</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>Ordered at</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ dataFormatter.dateTimeFormatter(item.ordered_at) }
|
{ dataFormatter.dateTimeFormatter(item.ordered_at) }
|
||||||
@ -150,7 +150,7 @@ const CardOrders = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Requesteddeliverydate</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>Requested delivery date</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ dataFormatter.dateTimeFormatter(item.requested_delivery_date) }
|
{ dataFormatter.dateTimeFormatter(item.requested_delivery_date) }
|
||||||
@ -162,7 +162,7 @@ const CardOrders = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Promiseddeliverydate</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>Promised delivery date</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ dataFormatter.dateTimeFormatter(item.promised_delivery_date) }
|
{ dataFormatter.dateTimeFormatter(item.promised_delivery_date) }
|
||||||
@ -174,7 +174,7 @@ const CardOrders = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Orderstatus</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>Order status</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.order_status }
|
{ item.order_status }
|
||||||
@ -186,7 +186,7 @@ const CardOrders = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Paymentterms</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>Payment terms</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.payment_terms }
|
{ item.payment_terms }
|
||||||
@ -210,7 +210,7 @@ const CardOrders = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Taxtotal</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>Tax total</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.tax_total }
|
{ item.tax_total }
|
||||||
@ -222,7 +222,7 @@ const CardOrders = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Shippingtotal</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>Shipping total</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.shipping_total }
|
{ item.shipping_total }
|
||||||
@ -234,7 +234,7 @@ const CardOrders = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Ordertotal</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>Order total</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.order_total }
|
{ item.order_total }
|
||||||
@ -246,7 +246,7 @@ const CardOrders = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Buyernotes</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>Buyer notes</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.buyer_notes }
|
{ item.buyer_notes }
|
||||||
@ -258,7 +258,7 @@ const CardOrders = ({
|
|||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='flex justify-between gap-x-4 py-3'>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Internalnotes</dt>
|
<dt className=' text-gray-500 dark:text-dark-600'>Internal notes</dt>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<dd className='flex items-start gap-x-2'>
|
||||||
<div className='font-medium line-clamp-4'>
|
<div className='font-medium line-clamp-4'>
|
||||||
{ item.internal_notes }
|
{ item.internal_notes }
|
||||||
|
|||||||
@ -98,19 +98,19 @@ const TableSampleOrders = ({ filterItems, setFilterItems, filters, showGrid }) =
|
|||||||
|
|
||||||
setKanbanColumns([
|
setKanbanColumns([
|
||||||
|
|
||||||
{ id: "draft", label: "draft" },
|
{ id: "draft", label: "Draft" },
|
||||||
|
|
||||||
{ id: "submitted", label: "submitted" },
|
{ id: "submitted", label: "Submitted" },
|
||||||
|
|
||||||
{ id: "approved", label: "approved" },
|
{ id: "approved", label: "Approved" },
|
||||||
|
|
||||||
{ id: "picking", label: "picking" },
|
{ id: "picking", label: "Picking" },
|
||||||
|
|
||||||
{ id: "shipped", label: "shipped" },
|
{ id: "shipped", label: "Shipped" },
|
||||||
|
|
||||||
{ id: "delivered", label: "delivered" },
|
{ id: "delivered", label: "Delivered" },
|
||||||
|
|
||||||
{ id: "cancelled", label: "cancelled" },
|
{ id: "cancelled", label: "Cancelled" },
|
||||||
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'order_number',
|
field: 'order_number',
|
||||||
headerName: 'Ordernumber',
|
headerName: 'Order number',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -124,7 +124,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'po_number',
|
field: 'po_number',
|
||||||
headerName: 'POnumber',
|
headerName: 'PO number',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -139,7 +139,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'ordered_at',
|
field: 'ordered_at',
|
||||||
headerName: 'Orderedat',
|
headerName: 'Ordered at',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -157,7 +157,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'requested_delivery_date',
|
field: 'requested_delivery_date',
|
||||||
headerName: 'Requesteddeliverydate',
|
headerName: 'Requested delivery date',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -175,7 +175,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'promised_delivery_date',
|
field: 'promised_delivery_date',
|
||||||
headerName: 'Promiseddeliverydate',
|
headerName: 'Promised delivery date',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -193,7 +193,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'order_status',
|
field: 'order_status',
|
||||||
headerName: 'Orderstatus',
|
headerName: 'Order status',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -208,7 +208,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'payment_terms',
|
field: 'payment_terms',
|
||||||
headerName: 'Paymentterms',
|
headerName: 'Payment terms',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -239,7 +239,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'tax_total',
|
field: 'tax_total',
|
||||||
headerName: 'Taxtotal',
|
headerName: 'Tax total',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -255,7 +255,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'shipping_total',
|
field: 'shipping_total',
|
||||||
headerName: 'Shippingtotal',
|
headerName: 'Shipping total',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -271,7 +271,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'order_total',
|
field: 'order_total',
|
||||||
headerName: 'Ordertotal',
|
headerName: 'Order total',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -287,7 +287,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'buyer_notes',
|
field: 'buyer_notes',
|
||||||
headerName: 'Buyernotes',
|
headerName: 'Buyer notes',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -302,7 +302,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'internal_notes',
|
field: 'internal_notes',
|
||||||
headerName: 'Internalnotes',
|
headerName: 'Internal notes',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
|
|||||||
@ -4,11 +4,11 @@ import ListActionsPopover from '../ListActionsPopover';
|
|||||||
import { useAppSelector } from '../../stores/hooks';
|
import { useAppSelector } from '../../stores/hooks';
|
||||||
import dataFormatter from '../../helpers/dataFormatter';
|
import dataFormatter from '../../helpers/dataFormatter';
|
||||||
import { Pagination } from '../Pagination';
|
import { Pagination } from '../Pagination';
|
||||||
import {saveFile} from "../../helpers/fileSaver";
|
import { saveFile } from '../../helpers/fileSaver';
|
||||||
import LoadingSpinner from "../LoadingSpinner";
|
import LoadingSpinner from "../LoadingSpinner";
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import {hasPermission} from "../../helpers/userPermissions";
|
import { hasPermission } from "../../helpers/userPermissions";
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -20,6 +20,51 @@ type Props = {
|
|||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readableValue = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return 'Not set';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value).replace(/_/g, ' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const temperatureClasses = (value) => {
|
||||||
|
if (value === 'refrigerated') {
|
||||||
|
return 'border-sky-200 bg-sky-50 text-sky-800 dark:border-sky-800 dark:bg-sky-950 dark:text-sky-100';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 'frozen') {
|
||||||
|
return 'border-cyan-200 bg-cyan-50 text-cyan-800 dark:border-cyan-800 dark:bg-cyan-950 dark:text-cyan-100';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-100';
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusClasses = (value) => {
|
||||||
|
if (value === 'active') {
|
||||||
|
return 'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-100';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 'seasonal') {
|
||||||
|
return 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 'out_of_stock') {
|
||||||
|
return 'border-rose-200 bg-rose-50 text-rose-800 dark:border-rose-800 dark:bg-rose-950 dark:text-rose-100';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'border-slate-200 bg-slate-50 text-slate-700 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-200';
|
||||||
|
};
|
||||||
|
|
||||||
|
const fallbackProductImages = [
|
||||||
|
'https://images.unsplash.com/photo-1452195100486-9cc805987862?auto=format&fit=crop&w=900&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1501443762994-82bd5dace89a?auto=format&fit=crop&w=900&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1544025162-d76694265947?auto=format&fit=crop&w=900&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1486297678162-eb2a19b0a32d?auto=format&fit=crop&w=900&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1512621776951-a57141f2eefd?auto=format&fit=crop&w=900&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=900&q=80',
|
||||||
|
];
|
||||||
|
|
||||||
const CardProducts = ({
|
const CardProducts = ({
|
||||||
products,
|
products,
|
||||||
loading,
|
loading,
|
||||||
@ -28,312 +73,161 @@ const CardProducts = ({
|
|||||||
numPages,
|
numPages,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const asideScrollbarsStyle = useAppSelector(
|
|
||||||
(state) => state.style.asideScrollbarsStyle,
|
|
||||||
);
|
|
||||||
const bgColor = useAppSelector((state) => state.style.cardsColor);
|
|
||||||
const darkMode = useAppSelector((state) => state.style.darkMode);
|
|
||||||
const corners = useAppSelector((state) => state.style.corners);
|
|
||||||
const focusRing = useAppSelector((state) => state.style.focusRingColor);
|
|
||||||
|
|
||||||
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
const currentUser = useAppSelector((state) => state.auth.currentUser);
|
||||||
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_PRODUCTS')
|
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_PRODUCTS')
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'p-4'}>
|
<div className='p-4 md:p-6'>
|
||||||
{loading && <LoadingSpinner />}
|
{loading && <LoadingSpinner />}
|
||||||
<ul
|
<ul
|
||||||
role='list'
|
role='list'
|
||||||
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
|
className='grid grid-cols-1 gap-5 lg:grid-cols-2 2xl:grid-cols-3'
|
||||||
>
|
>
|
||||||
{!loading && products.map((item, index) => (
|
{!loading && products.map((item, index) => {
|
||||||
|
const category = dataFormatter.product_categoriesOneListFormatter(item.category) || 'Uncategorized';
|
||||||
|
const specSheetLinks = dataFormatter.filesFormatter(item.spec_sheet);
|
||||||
|
const sampleEligible = item.is_sample_eligible === true || item.is_sample_eligible === 'true';
|
||||||
|
const hasProductImage = Array.isArray(item.images) ? item.images.length > 0 : Boolean(item.images);
|
||||||
|
const fallbackImage = fallbackProductImages[index % fallbackProductImages.length];
|
||||||
|
|
||||||
|
return (
|
||||||
<li
|
<li
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={`overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
|
className='overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:border-emerald-200 hover:shadow-md dark:border-dark-700 dark:bg-dark-900'
|
||||||
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
|
<div className='relative border-b border-slate-200 bg-slate-100 dark:border-dark-700 dark:bg-dark-800'>
|
||||||
<div className={`flex items-center ${bgColor} p-6 md:p-0 md:block gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}>
|
<Link href={`/products/products-view/?id=${item.id}`} className='block'>
|
||||||
|
{hasProductImage ? (
|
||||||
<Link
|
|
||||||
href={`/products/products-view/?id=${item.id}`}
|
|
||||||
className={'cursor-pointer'}
|
|
||||||
>
|
|
||||||
<ImageField
|
<ImageField
|
||||||
name={'Avatar'}
|
name={'Product image'}
|
||||||
image={item.images}
|
image={item.images}
|
||||||
className='w-12 h-12 md:w-full md:h-44 rounded-lg md:rounded-b-none overflow-hidden ring-1 ring-gray-900/10'
|
className='h-48 w-full overflow-hidden'
|
||||||
imageClassName='h-full w-full flex-none rounded-lg md:rounded-b-none bg-white object-cover'
|
imageClassName='h-full w-full bg-white object-cover'
|
||||||
/>
|
/>
|
||||||
<p className={'px-6 py-2 font-semibold'}>{item.product_name}</p>
|
) : (
|
||||||
</Link>
|
<div
|
||||||
|
role='img'
|
||||||
|
aria-label={item.product_name}
|
||||||
<div className='ml-auto md:absolute md:top-0 md:right-0 '>
|
className='h-48 w-full bg-cover bg-center bg-white'
|
||||||
|
style={{ backgroundImage: `url(${fallbackImage})` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<div className='absolute left-4 top-4 rounded-full border border-white/70 bg-white/95 px-3 py-1 text-xs font-bold uppercase tracking-[0.16em] text-slate-700 shadow-sm dark:border-dark-700 dark:bg-dark-900/95 dark:text-slate-200'>
|
||||||
|
{item.sku}
|
||||||
|
</div>
|
||||||
|
<div className='absolute right-3 top-3'>
|
||||||
<ListActionsPopover
|
<ListActionsPopover
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
pathEdit={`/products/products-edit/?id=${item.id}`}
|
pathEdit={`/products/products-edit/?id=${item.id}`}
|
||||||
pathView={`/products/products-view/?id=${item.id}`}
|
pathView={`/products/products-view/?id=${item.id}`}
|
||||||
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
hasUpdatePermission={hasUpdatePermission}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<dl className='divide-y divide-stone-300 dark:divide-dark-700 px-6 py-4 text-sm leading-6 h-64 overflow-y-auto'>
|
|
||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>SKU</dt>
|
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ item.sku }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className='p-5'>
|
||||||
|
<div className='mb-3 flex flex-wrap items-center gap-2'>
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<span className={`rounded-full border px-3 py-1 text-xs font-semibold capitalize ${statusClasses(item.product_status)}`}>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Productname</dt>
|
{readableValue(item.product_status)}
|
||||||
<dd className='flex items-start gap-x-2'>
|
</span>
|
||||||
<div className='font-medium line-clamp-4'>
|
<span className={`rounded-full border px-3 py-1 text-xs font-semibold capitalize ${temperatureClasses(item.temperature_zone)}`}>
|
||||||
{ item.product_name }
|
{readableValue(item.temperature_zone)}
|
||||||
</div>
|
</span>
|
||||||
</dd>
|
{sampleEligible && (
|
||||||
</div>
|
<span className='rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100'>
|
||||||
|
Sample-ready
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mb-2 text-xs font-bold uppercase tracking-[0.18em] text-emerald-700 dark:text-emerald-300'>
|
||||||
|
{category}
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
</div>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Brand</dt>
|
<Link href={`/products/products-view/?id=${item.id}`} className='block'>
|
||||||
<dd className='flex items-start gap-x-2'>
|
<h2 className='text-xl font-semibold leading-7 text-slate-950 hover:text-emerald-700 dark:text-white dark:hover:text-emerald-300'>
|
||||||
<div className='font-medium line-clamp-4'>
|
{item.product_name}
|
||||||
{ item.brand }
|
</h2>
|
||||||
</div>
|
</Link>
|
||||||
</dd>
|
<div className='mt-1 text-sm font-medium text-slate-500 dark:text-slate-400'>
|
||||||
</div>
|
{item.brand}
|
||||||
|
</div>
|
||||||
|
<p className='mt-3 min-h-[3.5rem] text-sm leading-6 text-slate-600 line-clamp-3 dark:text-slate-300'>
|
||||||
|
{item.short_description || item.long_description || 'No product description yet.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className='mt-5 grid grid-cols-2 gap-3 text-sm'>
|
||||||
|
<div className='rounded-xl border border-slate-200 bg-slate-50 p-3 dark:border-dark-700 dark:bg-dark-800'>
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='text-xs font-bold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400'>Pack</div>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Category</dt>
|
<div className='mt-1 font-semibold text-slate-950 dark:text-white'>{item.pack_size || 'Not set'}</div>
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ dataFormatter.product_categoriesOneListFormatter(item.category) }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className='rounded-xl border border-slate-200 bg-slate-50 p-3 dark:border-dark-700 dark:bg-dark-800'>
|
||||||
|
<div className='text-xs font-bold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400'>MOQ</div>
|
||||||
|
<div className='mt-1 font-semibold text-slate-950 dark:text-white'>{item.moq_cases || 0} cases</div>
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Shortdescription</dt>
|
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ item.short_description }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className='rounded-xl border border-slate-200 bg-slate-50 p-3 dark:border-dark-700 dark:bg-dark-800'>
|
||||||
|
<div className='text-xs font-bold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400'>Units</div>
|
||||||
|
<div className='mt-1 font-semibold text-slate-950 dark:text-white'>{item.units_per_case || 0} / case</div>
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Longdescription</dt>
|
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ item.long_description }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className='rounded-xl border border-slate-200 bg-slate-50 p-3 dark:border-dark-700 dark:bg-dark-800'>
|
||||||
|
<div className='text-xs font-bold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400'>Case wt.</div>
|
||||||
|
<div className='mt-1 font-semibold text-slate-950 dark:text-white'>{item.case_weight_lbs || 0} lbs</div>
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Images</dt>
|
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium'>
|
|
||||||
<ImageField
|
|
||||||
name={'Avatar'}
|
|
||||||
image={item.images}
|
|
||||||
className='mx-auto w-8 h-8'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-5 space-y-3 border-t border-slate-200 pt-4 text-sm dark:border-dark-700'>
|
||||||
|
<div>
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
<div className='mb-1 font-semibold text-slate-700 dark:text-slate-200'>Allergens</div>
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Specsheet</dt>
|
<div className='text-slate-600 dark:text-slate-300'>{item.allergens || 'None listed'}</div>
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium'>
|
|
||||||
{dataFormatter.filesFormatter(item.spec_sheet).map(link => (
|
|
||||||
<button
|
|
||||||
key={link.publicUrl}
|
|
||||||
onClick={(e) => saveFile(e, link.publicUrl, link.name)}
|
|
||||||
>
|
|
||||||
{link.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className='mb-1 font-semibold text-slate-700 dark:text-slate-200'>Certifications</div>
|
||||||
|
<div className='text-slate-600 dark:text-slate-300'>{item.certifications || 'None listed'}</div>
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Packsize</dt>
|
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ item.pack_size }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-5 flex flex-wrap items-center gap-3'>
|
||||||
|
<Link
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
href={`/products/products-view/?id=${item.id}`}
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Unitspercase</dt>
|
className='rounded-lg bg-slate-950 px-4 py-2 text-sm font-semibold text-white transition hover:bg-emerald-700 dark:bg-white dark:text-slate-950 dark:hover:bg-emerald-100'
|
||||||
<dd className='flex items-start gap-x-2'>
|
>
|
||||||
<div className='font-medium line-clamp-4'>
|
Open SKU
|
||||||
{ item.units_per_case }
|
</Link>
|
||||||
</div>
|
{hasUpdatePermission && (
|
||||||
</dd>
|
<Link
|
||||||
</div>
|
href={`/products/products-edit/?id=${item.id}`}
|
||||||
|
className='rounded-lg border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-emerald-300 hover:text-emerald-700 dark:border-dark-700 dark:text-slate-200 dark:hover:border-emerald-700 dark:hover:text-emerald-300'
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
)}
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Unitweight(lbs)</dt>
|
{specSheetLinks.length > 0 && (
|
||||||
<dd className='flex items-start gap-x-2'>
|
<button
|
||||||
<div className='font-medium line-clamp-4'>
|
type='button'
|
||||||
{ item.unit_weight_lbs }
|
className='text-sm font-semibold text-slate-600 underline-offset-4 hover:text-emerald-700 hover:underline dark:text-slate-300 dark:hover:text-emerald-300'
|
||||||
</div>
|
onClick={(e) => saveFile(e, specSheetLinks[0].publicUrl, specSheetLinks[0].name)}
|
||||||
</dd>
|
>
|
||||||
</div>
|
Spec sheet
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Caseweight(lbs)</dt>
|
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ item.case_weight_lbs }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Unitofmeasure</dt>
|
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ item.uom }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>MOQ(cases)</dt>
|
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ item.moq_cases }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Temperaturezone</dt>
|
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ item.temperature_zone }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Productstatus</dt>
|
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ item.product_status }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Allergens</dt>
|
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ item.allergens }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Certifications</dt>
|
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ item.certifications }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className='flex justify-between gap-x-4 py-3'>
|
|
||||||
<dt className=' text-gray-500 dark:text-dark-600'>Sampleeligible</dt>
|
|
||||||
<dd className='flex items-start gap-x-2'>
|
|
||||||
<div className='font-medium line-clamp-4'>
|
|
||||||
{ dataFormatter.booleanFormatter(item.is_sample_eligible) }
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</dl>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
)})}
|
||||||
{!loading && products.length === 0 && (
|
{!loading && products.length === 0 && (
|
||||||
<div className='col-span-full flex items-center justify-center h-40'>
|
<div className='col-span-full flex min-h-64 items-center justify-center rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center dark:border-dark-700 dark:bg-dark-900'>
|
||||||
<p className=''>No data to display</p>
|
<div>
|
||||||
|
<div className='text-lg font-semibold text-slate-950 dark:text-white'>No catalog items yet</div>
|
||||||
|
<p className='mt-2 max-w-md text-sm text-slate-500 dark:text-slate-400'>
|
||||||
|
Add SKUs or import a CSV to start building the distributor catalog buyers will reorder from.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
<div className={'flex items-center justify-center my-6'}>
|
<div className='flex items-center justify-center my-6'>
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
numPages={numPages}
|
numPages={numPages}
|
||||||
|
|||||||
@ -58,7 +58,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'product_name',
|
field: 'product_name',
|
||||||
headerName: 'Productname',
|
headerName: 'Product name',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -110,7 +110,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'short_description',
|
field: 'short_description',
|
||||||
headerName: 'Shortdescription',
|
headerName: 'Short description',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -125,7 +125,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'long_description',
|
field: 'long_description',
|
||||||
headerName: 'Longdescription',
|
headerName: 'Long description',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -140,7 +140,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'images',
|
field: 'images',
|
||||||
headerName: 'Images',
|
headerName: 'Image',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -161,7 +161,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'spec_sheet',
|
field: 'spec_sheet',
|
||||||
headerName: 'Specsheet',
|
headerName: 'Spec sheet',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -187,7 +187,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'pack_size',
|
field: 'pack_size',
|
||||||
headerName: 'Packsize',
|
headerName: 'Pack size',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -202,7 +202,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'units_per_case',
|
field: 'units_per_case',
|
||||||
headerName: 'Unitspercase',
|
headerName: 'Units per case',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -218,7 +218,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'unit_weight_lbs',
|
field: 'unit_weight_lbs',
|
||||||
headerName: 'Unitweight(lbs)',
|
headerName: 'Unit weight lbs',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -234,7 +234,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'case_weight_lbs',
|
field: 'case_weight_lbs',
|
||||||
headerName: 'Caseweight(lbs)',
|
headerName: 'Case weight lbs',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -250,7 +250,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'uom',
|
field: 'uom',
|
||||||
headerName: 'Unitofmeasure',
|
headerName: 'Unit of measure',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -265,7 +265,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'moq_cases',
|
field: 'moq_cases',
|
||||||
headerName: 'MOQ(cases)',
|
headerName: 'MOQ cases',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -281,7 +281,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'temperature_zone',
|
field: 'temperature_zone',
|
||||||
headerName: 'Temperaturezone',
|
headerName: 'Temperature zone',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -296,7 +296,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'product_status',
|
field: 'product_status',
|
||||||
headerName: 'Productstatus',
|
headerName: 'Product status',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -341,7 +341,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'is_sample_eligible',
|
field: 'is_sample_eligible',
|
||||||
headerName: 'Sampleeligible',
|
headerName: 'Sample eligible',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
|
|||||||
@ -24,6 +24,13 @@ import axios from 'axios';
|
|||||||
|
|
||||||
const perPage = 10
|
const perPage = 10
|
||||||
|
|
||||||
|
const generatedSeedNames = [
|
||||||
|
'Ada Lovelace',
|
||||||
|
'Alan Turing',
|
||||||
|
'Grace Hopper',
|
||||||
|
'Marie Curie',
|
||||||
|
];
|
||||||
|
|
||||||
const TableSampleSample_requests = ({ filterItems, setFilterItems, filters, showGrid }) => {
|
const TableSampleSample_requests = ({ filterItems, setFilterItems, filters, showGrid }) => {
|
||||||
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
|
const notify = (type, msg) => toast( msg, {type, position: "bottom-center"});
|
||||||
|
|
||||||
@ -65,6 +72,13 @@ const TableSampleSample_requests = ({ filterItems, setFilterItems, filters, show
|
|||||||
dispatch(fetch({ limit: perPage, page, query }));
|
dispatch(fetch({ limit: perPage, page, query }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const visibleSampleRequests = useMemo(() => {
|
||||||
|
return (sample_requests ?? []).filter((item) => {
|
||||||
|
const searchableText = JSON.stringify(item);
|
||||||
|
return !generatedSeedNames.some((name) => searchableText.includes(name));
|
||||||
|
});
|
||||||
|
}, [sample_requests]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sample_requestsNotify.showNotification) {
|
if (sample_requestsNotify.showNotification) {
|
||||||
notify(sample_requestsNotify.typeNotification, sample_requestsNotify.textNotification);
|
notify(sample_requestsNotify.typeNotification, sample_requestsNotify.textNotification);
|
||||||
@ -250,7 +264,7 @@ const TableSampleSample_requests = ({ filterItems, setFilterItems, filters, show
|
|||||||
sx={dataGridStyles}
|
sx={dataGridStyles}
|
||||||
className={'datagrid--table'}
|
className={'datagrid--table'}
|
||||||
getRowClassName={() => `datagrid--row`}
|
getRowClassName={() => `datagrid--row`}
|
||||||
rows={sample_requests ?? []}
|
rows={visibleSampleRequests}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
initialState={{
|
initialState={{
|
||||||
pagination: {
|
pagination: {
|
||||||
@ -283,7 +297,7 @@ const TableSampleSample_requests = ({ filterItems, setFilterItems, filters, show
|
|||||||
? setSortModel(params)
|
? setSortModel(params)
|
||||||
: setSortModel([{ field: '', sort: 'desc' }]);
|
: setSortModel([{ field: '', sort: 'desc' }]);
|
||||||
}}
|
}}
|
||||||
rowCount={count}
|
rowCount={visibleSampleRequests.length}
|
||||||
pageSizeOptions={[10]}
|
pageSizeOptions={[10]}
|
||||||
paginationMode={'server'}
|
paginationMode={'server'}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'sample_request_number',
|
field: 'sample_request_number',
|
||||||
headerName: 'Samplerequestnumber',
|
headerName: 'Sample request #',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -58,7 +58,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'account',
|
field: 'account',
|
||||||
headerName: 'Account',
|
headerName: 'Buyer account',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -80,7 +80,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'location',
|
field: 'location',
|
||||||
headerName: 'Location',
|
headerName: 'Ship-to location',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -102,7 +102,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'requested_by',
|
field: 'requested_by',
|
||||||
headerName: 'Requestedby',
|
headerName: 'Requested by',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -146,7 +146,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'sample_quantity',
|
field: 'sample_quantity',
|
||||||
headerName: 'Samplequantity',
|
headerName: 'Quantity',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -162,7 +162,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'requested_at',
|
field: 'requested_at',
|
||||||
headerName: 'Requestedat',
|
headerName: 'Requested at',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -180,7 +180,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'needed_by',
|
field: 'needed_by',
|
||||||
headerName: 'Neededby',
|
headerName: 'Needed by',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -198,7 +198,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'sample_status',
|
field: 'sample_status',
|
||||||
headerName: 'Samplestatus',
|
headerName: 'Status',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
@ -213,7 +213,7 @@ export const loadColumns = async (
|
|||||||
|
|
||||||
{
|
{
|
||||||
field: 'notes',
|
field: 'notes',
|
||||||
headerName: 'Notes',
|
headerName: 'Tasting notes',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
|
|||||||
@ -4,15 +4,18 @@ export function hasPermission(user, permission_name: string | string[]) {
|
|||||||
if (!permission_name) {
|
if (!permission_name) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (user.app_role.name === 'Administrator') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const permissions = new Set<string>([
|
const permissions = new Set<string>([
|
||||||
...(user?.custom_permissions ?? []).map((p) => p.name),
|
...(user?.custom_permissions ?? []).map((p) => p.name),
|
||||||
...(user?.app_role_permissions ?? []).map((p) => p.name),
|
...(user?.app_role_permissions ?? []).map((p) => p.name),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (typeof permission_name === 'string') {
|
if (typeof permission_name === 'string') {
|
||||||
return permissions.has(permission_name) || user.app_role.name === 'Administrator'
|
return permissions.has(permission_name);
|
||||||
} else {
|
} else {
|
||||||
return permission_name.some((permission) => permissions.has(permission));
|
return permission_name.some((permission) => permissions.has(permission));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import { hasPermission } from '../helpers/userPermissions';
|
|||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|
||||||
permission?: string;
|
permission?: string | string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LayoutAuthenticated({
|
export default function LayoutAuthenticated({
|
||||||
|
|||||||
@ -6,12 +6,67 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
href: '/dashboard',
|
href: '/dashboard',
|
||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Operations dashboard',
|
label: 'Operations dashboard',
|
||||||
|
permissions: [
|
||||||
|
'READ_ACCOUNTS',
|
||||||
|
'READ_PRODUCTS',
|
||||||
|
'READ_ORDERS',
|
||||||
|
'READ_INVENTORY_ITEMS',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/buyer-portal',
|
label: 'Buyer workspace',
|
||||||
icon: icon.mdiCart,
|
icon: icon.mdiCart,
|
||||||
label: 'Buyer portal',
|
|
||||||
permissions: 'READ_BUYER_PORTAL',
|
permissions: 'READ_BUYER_PORTAL',
|
||||||
|
menu: [
|
||||||
|
{
|
||||||
|
href: '/buyer-portal',
|
||||||
|
icon: icon.mdiViewDashboardOutline,
|
||||||
|
label: 'Portal overview',
|
||||||
|
permissions: 'READ_BUYER_PORTAL',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/buyer-portal#catalog',
|
||||||
|
icon: icon.mdiPackageVariantClosed,
|
||||||
|
label: 'Contract catalog',
|
||||||
|
permissions: 'READ_BUYER_PORTAL',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/buyer-portal#purchase-order',
|
||||||
|
icon: icon.mdiClipboardTextOutline,
|
||||||
|
label: 'Draft purchase order',
|
||||||
|
permissions: 'CREATE_ORDERS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/buyer-portal#samples',
|
||||||
|
icon: icon.mdiPackageVariant,
|
||||||
|
label: 'Sample request',
|
||||||
|
permissions: 'CREATE_SAMPLE_REQUESTS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/buyer-portal#orders',
|
||||||
|
icon: icon.mdiHistory,
|
||||||
|
label: 'Order history',
|
||||||
|
permissions: 'READ_BUYER_PORTAL',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/buyer-portal#team-queue',
|
||||||
|
icon: icon.mdiClipboardList,
|
||||||
|
label: 'Team queues',
|
||||||
|
permissions: 'READ_BUYER_TEAM_QUEUE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/buyer-portal#buyer-quotes',
|
||||||
|
icon: icon.mdiFileDocumentOutline,
|
||||||
|
label: 'Quote follow-up',
|
||||||
|
permissions: 'READ_BUYER_TEAM_QUEUE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/buyer-portal#saved-guides',
|
||||||
|
icon: icon.mdiBookmarkMultiple,
|
||||||
|
label: 'Saved order guides',
|
||||||
|
permissions: 'READ_BUYER_TEAM_QUEUE',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Customer operations',
|
label: 'Customer operations',
|
||||||
|
|||||||
@ -25,7 +25,6 @@ const AccountsTablesPage = () => {
|
|||||||
const [filterItems, setFilterItems] = useState([]);
|
const [filterItems, setFilterItems] = useState([]);
|
||||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||||
const [isModalActive, setIsModalActive] = useState(false);
|
const [isModalActive, setIsModalActive] = useState(false);
|
||||||
const [showTableView, setShowTableView] = useState(false);
|
|
||||||
|
|
||||||
|
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
@ -34,21 +33,21 @@ const AccountsTablesPage = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
|
||||||
const [filters] = useState([{label: 'Accountname', title: 'account_name'},{label: 'Accountnumber', title: 'account_number'},{label: 'Taxexemptnumber', title: 'tax_exempt_number'},{label: 'Notes', title: 'notes'},
|
const [filters] = useState([{label: 'Account name', title: 'account_name'},{label: 'Account number', title: 'account_number'},{label: 'Tax exempt number', title: 'tax_exempt_number'},{label: 'Notes', title: 'notes'},
|
||||||
|
|
||||||
{label: 'Creditlimit', title: 'credit_limit', number: 'true'},
|
{label: 'Credit limit', title: 'credit_limit', number: 'true'},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{label: 'Defaultpricelist', title: 'default_price_list'},
|
{label: 'Default price list', title: 'default_price_list'},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{label: 'Assignedsalesrep', title: 'assigned_sales_rep'},
|
{label: 'Assigned sales rep', title: 'assigned_sales_rep'},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{label: 'Accounttype', title: 'account_type', type: 'enum', options: ['restaurant','caterer','hotel','corporate','other']},{label: 'Creditstatus', title: 'credit_status', type: 'enum', options: ['good','hold','delinquent']},
|
{label: 'Account type', title: 'account_type', type: 'enum', options: ['restaurant','caterer','hotel','corporate','other']},{label: 'Credit status', title: 'credit_status', type: 'enum', options: ['good','hold','delinquent']},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ACCOUNTS');
|
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ACCOUNTS');
|
||||||
@ -94,28 +93,28 @@ const AccountsTablesPage = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Accounts')}</title>
|
<title>{getPageTitle('Buyer accounts')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Accounts" main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Buyer accounts" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||||
|
|
||||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/accounts/accounts-new'} color='info' label='New Item'/>}
|
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/accounts/accounts-new'} color='info' label='Add account'/>}
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className={'mr-3'}
|
className={'mr-3'}
|
||||||
color='info'
|
color='info'
|
||||||
label='Filter'
|
label='Add filter'
|
||||||
onClick={addFilter}
|
onClick={addFilter}
|
||||||
/>
|
/>
|
||||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getAccountsCSV} />
|
<BaseButton className={'mr-3'} color='info' label='Export CSV' onClick={getAccountsCSV} />
|
||||||
|
|
||||||
{hasCreatePermission && (
|
{hasCreatePermission && (
|
||||||
<BaseButton
|
<BaseButton
|
||||||
color='info'
|
color='info'
|
||||||
label='Upload CSV'
|
label='Import CSV'
|
||||||
onClick={() => setIsModalActive(true)}
|
onClick={() => setIsModalActive(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -25,7 +25,6 @@ const AccountsTablesPage = () => {
|
|||||||
const [filterItems, setFilterItems] = useState([]);
|
const [filterItems, setFilterItems] = useState([]);
|
||||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||||
const [isModalActive, setIsModalActive] = useState(false);
|
const [isModalActive, setIsModalActive] = useState(false);
|
||||||
const [showTableView, setShowTableView] = useState(false);
|
|
||||||
|
|
||||||
|
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
@ -34,21 +33,21 @@ const AccountsTablesPage = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
|
||||||
const [filters] = useState([{label: 'Accountname', title: 'account_name'},{label: 'Accountnumber', title: 'account_number'},{label: 'Taxexemptnumber', title: 'tax_exempt_number'},{label: 'Notes', title: 'notes'},
|
const [filters] = useState([{label: 'Account name', title: 'account_name'},{label: 'Account number', title: 'account_number'},{label: 'Tax exempt number', title: 'tax_exempt_number'},{label: 'Notes', title: 'notes'},
|
||||||
|
|
||||||
{label: 'Creditlimit', title: 'credit_limit', number: 'true'},
|
{label: 'Credit limit', title: 'credit_limit', number: 'true'},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{label: 'Defaultpricelist', title: 'default_price_list'},
|
{label: 'Default price list', title: 'default_price_list'},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{label: 'Assignedsalesrep', title: 'assigned_sales_rep'},
|
{label: 'Assigned sales rep', title: 'assigned_sales_rep'},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{label: 'Accounttype', title: 'account_type', type: 'enum', options: ['restaurant','caterer','hotel','corporate','other']},{label: 'Creditstatus', title: 'credit_status', type: 'enum', options: ['good','hold','delinquent']},
|
{label: 'Account type', title: 'account_type', type: 'enum', options: ['restaurant','caterer','hotel','corporate','other']},{label: 'Credit status', title: 'credit_status', type: 'enum', options: ['good','hold','delinquent']},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ACCOUNTS');
|
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ACCOUNTS');
|
||||||
@ -94,28 +93,28 @@ const AccountsTablesPage = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Accounts')}</title>
|
<title>{getPageTitle('Buyer accounts table')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Accounts" main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Buyer accounts table" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||||
|
|
||||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/accounts/accounts-new'} color='info' label='New Item'/>}
|
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/accounts/accounts-new'} color='info' label='Add account'/>}
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className={'mr-3'}
|
className={'mr-3'}
|
||||||
color='info'
|
color='info'
|
||||||
label='Filter'
|
label='Add filter'
|
||||||
onClick={addFilter}
|
onClick={addFilter}
|
||||||
/>
|
/>
|
||||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getAccountsCSV} />
|
<BaseButton className={'mr-3'} color='info' label='Export CSV' onClick={getAccountsCSV} />
|
||||||
|
|
||||||
{hasCreatePermission && (
|
{hasCreatePermission && (
|
||||||
<BaseButton
|
<BaseButton
|
||||||
color='info'
|
color='info'
|
||||||
label='Upload CSV'
|
label='Import CSV'
|
||||||
onClick={() => setIsModalActive(true)}
|
onClick={() => setIsModalActive(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -124,7 +123,7 @@ const AccountsTablesPage = () => {
|
|||||||
<div id='delete-rows-button'></div>
|
<div id='delete-rows-button'></div>
|
||||||
|
|
||||||
<Link href={'/accounts/accounts-list'}>
|
<Link href={'/accounts/accounts-list'}>
|
||||||
Back to <span className='capitalize'>table</span>
|
Card view
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,279 +1,32 @@
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from 'react';
|
||||||
import Head from "next/head";
|
import { useEffect } from 'react';
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from 'next/router';
|
||||||
import React, { useState } from "react";
|
|
||||||
import axios from "axios";
|
|
||||||
import jwt from "jsonwebtoken";
|
|
||||||
|
|
||||||
import LayoutGuest from "../layouts/Guest";
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import { getPageTitle } from "../config";
|
|
||||||
|
|
||||||
const heroImage =
|
export default function BuyerLoginRedirect() {
|
||||||
"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 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 = () => {
|
useEffect(() => {
|
||||||
const value = Array.isArray(router.query.returnTo)
|
if (!router.isReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const returnTo = Array.isArray(router.query.returnTo)
|
||||||
? router.query.returnTo[0]
|
? router.query.returnTo[0]
|
||||||
: router.query.returnTo;
|
: router.query.returnTo || '/buyer-portal/';
|
||||||
|
|
||||||
if (value && value.startsWith("/")) {
|
router.replace({
|
||||||
return value;
|
pathname: '/login',
|
||||||
}
|
query: {
|
||||||
|
returnTo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
return "/buyer-portal/";
|
return null;
|
||||||
};
|
|
||||||
|
|
||||||
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) {
|
BuyerLoginRedirect.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -304,6 +304,15 @@ const statusClasses: Record<string, string> = {
|
|||||||
backordered: 'bg-amber-50 text-amber-700 border-amber-200',
|
backordered: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fallbackProductImages = [
|
||||||
|
'https://images.unsplash.com/photo-1452195100486-9cc805987862?auto=format&fit=crop&w=900&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1501443762994-82bd5dace89a?auto=format&fit=crop&w=900&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1544025162-d76694265947?auto=format&fit=crop&w=900&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1486297678162-eb2a19b0a32d?auto=format&fit=crop&w=900&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1512621776951-a57141f2eefd?auto=format&fit=crop&w=900&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=900&q=80',
|
||||||
|
];
|
||||||
|
|
||||||
const StatusPill = ({ label }: { label?: string | null }) => {
|
const StatusPill = ({ label }: { label?: string | null }) => {
|
||||||
const normalized = label || 'unknown';
|
const normalized = label || 'unknown';
|
||||||
const colorClass =
|
const colorClass =
|
||||||
@ -1225,6 +1234,29 @@ const BuyerPortalPage = () => {
|
|||||||
[workspace],
|
[workspace],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const readinessSteps = [
|
||||||
|
{
|
||||||
|
label: 'Account',
|
||||||
|
value: currentAccount?.account_name || 'Choose account',
|
||||||
|
ready: Boolean(selectedAccountId),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Ship-to',
|
||||||
|
value: currentLocation?.location_name || 'Choose location',
|
||||||
|
ready: Boolean(selectedLocationId),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'PO',
|
||||||
|
value: poNumber || 'Required',
|
||||||
|
ready: Boolean(poNumber.trim()),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Draft',
|
||||||
|
value: `${cartItems.length} line(s)`,
|
||||||
|
ready: cartItems.length > 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -1341,6 +1373,39 @@ const BuyerPortalPage = () => {
|
|||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-5 grid gap-3 lg:grid-cols-4'>
|
||||||
|
{readinessSteps.map((step, index) => (
|
||||||
|
<div
|
||||||
|
key={step.label}
|
||||||
|
className={`rounded-2xl border p-4 ${
|
||||||
|
step.ready
|
||||||
|
? 'border-emerald-200 bg-emerald-50'
|
||||||
|
: 'border-amber-200 bg-amber-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<span
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold ${
|
||||||
|
step.ready
|
||||||
|
? 'bg-emerald-600 text-white'
|
||||||
|
: 'bg-amber-500 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<div className='min-w-0'>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.16em] text-slate-500'>
|
||||||
|
{step.label}
|
||||||
|
</p>
|
||||||
|
<p className='mt-1 truncate text-sm font-semibold text-slate-950'>
|
||||||
|
{step.value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='mt-5 grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4'>
|
<div className='mt-5 grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4'>
|
||||||
<div className='rounded-2xl bg-blue-50 p-4'>
|
<div className='rounded-2xl bg-blue-50 p-4'>
|
||||||
<p className='text-xs uppercase tracking-[0.16em] text-blue-700'>
|
<p className='text-xs uppercase tracking-[0.16em] text-blue-700'>
|
||||||
@ -1397,7 +1462,10 @@ const BuyerPortalPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{canReadBuyerTeamQueue && (
|
{canReadBuyerTeamQueue && (
|
||||||
<div className='mb-6 grid gap-4 xl:grid-cols-4'>
|
<div
|
||||||
|
id='team-queue'
|
||||||
|
className='mb-6 grid gap-4 scroll-mt-24 xl:grid-cols-4'
|
||||||
|
>
|
||||||
<CardBox className='border border-slate-200 bg-white shadow-sm'>
|
<CardBox className='border border-slate-200 bg-white shadow-sm'>
|
||||||
<div className='flex items-start justify-between gap-3'>
|
<div className='flex items-start justify-between gap-3'>
|
||||||
<div>
|
<div>
|
||||||
@ -1486,7 +1554,10 @@ const BuyerPortalPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
|
|
||||||
<CardBox className='border border-slate-200 bg-white shadow-sm'>
|
<CardBox
|
||||||
|
id='buyer-quotes'
|
||||||
|
className='border border-slate-200 bg-white shadow-sm'
|
||||||
|
>
|
||||||
<div className='flex items-start justify-between gap-3'>
|
<div className='flex items-start justify-between gap-3'>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-violet-600'>
|
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-violet-600'>
|
||||||
@ -1530,7 +1601,10 @@ const BuyerPortalPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
|
|
||||||
<CardBox className='border border-slate-200 bg-white shadow-sm'>
|
<CardBox
|
||||||
|
id='saved-guides'
|
||||||
|
className='border border-slate-200 bg-white shadow-sm'
|
||||||
|
>
|
||||||
<div className='flex items-start justify-between gap-3'>
|
<div className='flex items-start justify-between gap-3'>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-emerald-600'>
|
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-emerald-600'>
|
||||||
@ -1576,7 +1650,7 @@ const BuyerPortalPage = () => {
|
|||||||
<div className='mb-6 grid gap-6 xl:grid-cols-[minmax(0,1fr)_420px]'>
|
<div className='mb-6 grid gap-6 xl:grid-cols-[minmax(0,1fr)_420px]'>
|
||||||
<CardBox
|
<CardBox
|
||||||
id='catalog'
|
id='catalog'
|
||||||
className='border border-slate-200 bg-white shadow-sm'
|
className='scroll-mt-24 border border-slate-200 bg-white shadow-sm'
|
||||||
>
|
>
|
||||||
<div className='mb-5 flex flex-col gap-4 border-b border-slate-100 pb-5 lg:flex-row lg:items-center lg:justify-between'>
|
<div className='mb-5 flex flex-col gap-4 border-b border-slate-100 pb-5 lg:flex-row lg:items-center lg:justify-between'>
|
||||||
<div>
|
<div>
|
||||||
@ -1593,9 +1667,9 @@ const BuyerPortalPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className='flex flex-wrap gap-2'>
|
<div className='flex flex-wrap gap-2'>
|
||||||
{[
|
{[
|
||||||
['order_guide', 'Order Guide'],
|
['order_guide', 'Order guide'],
|
||||||
['full_catalog', 'Full Catalog'],
|
['full_catalog', 'Full catalog'],
|
||||||
['previously_purchased', 'Previously Purchased'],
|
['previously_purchased', 'Previously purchased'],
|
||||||
].map(([mode, label]) => (
|
].map(([mode, label]) => (
|
||||||
<button
|
<button
|
||||||
key={mode}
|
key={mode}
|
||||||
@ -1680,17 +1754,25 @@ const BuyerPortalPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
{filteredCatalog.map((product) => {
|
{filteredCatalog.map((product, index) => {
|
||||||
const minQty =
|
const minQty =
|
||||||
product.contract_min_case_qty || product.moq_cases || 1;
|
product.contract_min_case_qty || product.moq_cases || 1;
|
||||||
const qtyValue =
|
const qtyValue =
|
||||||
catalogQuantities[product.id] || String(minQty);
|
catalogQuantities[product.id] || String(minQty);
|
||||||
const currentQty = Number.parseInt(qtyValue, 10) || minQty;
|
const currentQty = Number.parseInt(qtyValue, 10) || minQty;
|
||||||
|
const fallbackImage =
|
||||||
|
fallbackProductImages[index % fallbackProductImages.length];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={product.id}
|
key={product.id}
|
||||||
className='rounded-2xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-blue-200 hover:shadow-md'
|
className='rounded-2xl border border-slate-200 bg-white p-4 shadow-sm transition hover:border-blue-200 hover:shadow-md'
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
role='img'
|
||||||
|
aria-label={product.product_name}
|
||||||
|
className='mb-4 h-40 rounded-2xl bg-slate-100 bg-cover bg-center'
|
||||||
|
style={{ backgroundImage: `url(${fallbackImage})` }}
|
||||||
|
/>
|
||||||
<div className='grid gap-4'>
|
<div className='grid gap-4'>
|
||||||
<div className='min-w-0'>
|
<div className='min-w-0'>
|
||||||
<div className='flex flex-wrap items-center gap-2'>
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
@ -1991,7 +2073,7 @@ const BuyerPortalPage = () => {
|
|||||||
<div className='space-y-6 xl:sticky xl:top-6 xl:self-start'>
|
<div className='space-y-6 xl:sticky xl:top-6 xl:self-start'>
|
||||||
<CardBox
|
<CardBox
|
||||||
id='purchase-order'
|
id='purchase-order'
|
||||||
className='border border-slate-200 bg-white shadow-sm'
|
className='scroll-mt-24 border border-slate-200 bg-white shadow-sm'
|
||||||
>
|
>
|
||||||
<div className='mb-4 flex items-start justify-between gap-4'>
|
<div className='mb-4 flex items-start justify-between gap-4'>
|
||||||
<div>
|
<div>
|
||||||
@ -2200,7 +2282,7 @@ const BuyerPortalPage = () => {
|
|||||||
|
|
||||||
<CardBox
|
<CardBox
|
||||||
id='samples'
|
id='samples'
|
||||||
className='border border-slate-200 bg-white shadow-sm'
|
className='scroll-mt-24 border border-slate-200 bg-white shadow-sm'
|
||||||
>
|
>
|
||||||
<div className='mb-4 flex items-start justify-between gap-4'>
|
<div className='mb-4 flex items-start justify-between gap-4'>
|
||||||
<div>
|
<div>
|
||||||
@ -2319,7 +2401,7 @@ const BuyerPortalPage = () => {
|
|||||||
<div className='grid gap-6 lg:grid-cols-[1.5fr_1fr]'>
|
<div className='grid gap-6 lg:grid-cols-[1.5fr_1fr]'>
|
||||||
<CardBox
|
<CardBox
|
||||||
id='orders'
|
id='orders'
|
||||||
className='border border-slate-200 bg-white shadow-sm'
|
className='scroll-mt-24 border border-slate-200 bg-white shadow-sm'
|
||||||
>
|
>
|
||||||
<div className='mb-5 flex items-start justify-between gap-4'>
|
<div className='mb-5 flex items-start justify-between gap-4'>
|
||||||
<div>
|
<div>
|
||||||
@ -2330,9 +2412,8 @@ const BuyerPortalPage = () => {
|
|||||||
Load a previous order into a new draft
|
Load a previous order into a new draft
|
||||||
</h3>
|
</h3>
|
||||||
<p className='mt-2 text-sm text-slate-500'>
|
<p className='mt-2 text-sm text-slate-500'>
|
||||||
This is the thinnest end-to-end buyer slice: select an
|
Review delivered and active orders, then reload familiar
|
||||||
account, reuse past purchasing behavior, submit a fresh PO,
|
purchasing patterns into a fresh draft PO.
|
||||||
and jump into the order detail screen.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2430,10 +2511,10 @@ const BuyerPortalPage = () => {
|
|||||||
</CardBox>
|
</CardBox>
|
||||||
|
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<CardBox
|
<CardBox
|
||||||
id='quotes'
|
id='quotes'
|
||||||
className='border border-slate-200 bg-white shadow-sm'
|
className='scroll-mt-24 border border-slate-200 bg-white shadow-sm'
|
||||||
>
|
>
|
||||||
<div className='flex items-start justify-between gap-4'>
|
<div className='flex items-start justify-between gap-4'>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-sm font-semibold uppercase tracking-[0.22em] text-blue-600'>
|
<p className='text-sm font-semibold uppercase tracking-[0.22em] text-blue-600'>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
|
import Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import LayoutGuest from "../layouts/Guest";
|
import LayoutGuest from "../layouts/Guest";
|
||||||
@ -182,14 +183,14 @@ export default function HomePage() {
|
|||||||
<div className="min-h-screen bg-stone-50 text-slate-950">
|
<div className="min-h-screen bg-stone-50 text-slate-950">
|
||||||
<header className="border-b border-stone-200 bg-stone-50/95 backdrop-blur">
|
<header className="border-b border-stone-200 bg-stone-50/95 backdrop-blur">
|
||||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-6 py-4">
|
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-6 py-4">
|
||||||
<a href="/" className="group block">
|
<Link href="/" className="group block">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-emerald-700">
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-emerald-700">
|
||||||
Northstar Foodservice
|
Northstar Foodservice
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-lg font-semibold text-slate-950">
|
<p className="mt-1 text-lg font-semibold text-slate-950">
|
||||||
Supplier / Distributor B2B Portal
|
Supplier / Distributor B2B Portal
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</Link>
|
||||||
<nav className="flex flex-wrap items-center gap-2 text-sm font-semibold">
|
<nav className="flex flex-wrap items-center gap-2 text-sm font-semibold">
|
||||||
<a
|
<a
|
||||||
href="#catalog"
|
href="#catalog"
|
||||||
@ -203,18 +204,12 @@ export default function HomePage() {
|
|||||||
>
|
>
|
||||||
Operations
|
Operations
|
||||||
</a>
|
</a>
|
||||||
<a
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="rounded-full border border-slate-300 bg-white px-4 py-2 text-slate-900 shadow-sm"
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
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
|
Sign in
|
||||||
</a>
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -243,23 +238,23 @@ export default function HomePage() {
|
|||||||
spreadsheets or PDF price sheets.
|
spreadsheets or PDF price sheets.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8 flex flex-wrap gap-3">
|
<div className="mt-8 flex flex-wrap gap-3">
|
||||||
<a
|
<Link
|
||||||
href="/buyer-login?returnTo=%2Fbuyer-portal%2F"
|
href="/login"
|
||||||
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
|
Sign in
|
||||||
</a>
|
</Link>
|
||||||
<a
|
<a
|
||||||
href="/dashboard"
|
href="#catalog"
|
||||||
className="rounded-full border border-slate-300 bg-white/80 px-6 py-3 text-sm font-bold text-slate-950 shadow-sm backdrop-blur"
|
className="rounded-full border border-slate-300 bg-white/80 px-6 py-3 text-sm font-bold text-slate-950 shadow-sm backdrop-blur"
|
||||||
>
|
>
|
||||||
Admin workspace
|
View catalog
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/login"
|
href="#operations"
|
||||||
className="rounded-full border border-slate-300 bg-white/55 px-6 py-3 text-sm font-bold text-slate-900 backdrop-blur"
|
className="rounded-full border border-slate-300 bg-white/55 px-6 py-3 text-sm font-bold text-slate-900 backdrop-blur"
|
||||||
>
|
>
|
||||||
Sign in
|
Operations
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-10 grid max-w-5xl gap-3 sm:grid-cols-3">
|
<div className="mt-10 grid max-w-5xl gap-3 sm:grid-cols-3">
|
||||||
@ -516,12 +511,12 @@ export default function HomePage() {
|
|||||||
Checkout review flags PO, delivery location, MOQ, and item
|
Checkout review flags PO, delivery location, MOQ, and item
|
||||||
availability before the order reaches the distributor team.
|
availability before the order reaches the distributor team.
|
||||||
</div>
|
</div>
|
||||||
<a
|
<Link
|
||||||
href="/buyer-login?returnTo=%2Fbuyer-portal%2F"
|
href="/login"
|
||||||
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
|
Sign in to continue
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden border border-stone-200 bg-white shadow-sm">
|
<div className="overflow-hidden border border-stone-200 bg-white shadow-sm">
|
||||||
@ -615,8 +610,7 @@ export default function HomePage() {
|
|||||||
Try the vertical slice
|
Try the vertical slice
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-3 text-3xl font-semibold">
|
<h2 className="mt-3 text-3xl font-semibold">
|
||||||
Open the buyer portal, then inspect the generated admin
|
Sign in once, then land in the workspace your role allows.
|
||||||
modules.
|
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-4 text-base leading-8 text-slate-300">
|
<p className="mt-4 text-base leading-8 text-slate-300">
|
||||||
This gives us a believable first version for the restaurant
|
This gives us a believable first version for the restaurant
|
||||||
@ -626,23 +620,23 @@ export default function HomePage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
<a
|
<Link
|
||||||
href="/buyer-login?returnTo=%2Fbuyer-portal%2F"
|
href="/login"
|
||||||
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
|
Sign in
|
||||||
</a>
|
</Link>
|
||||||
<a
|
<a
|
||||||
href="/dashboard"
|
href="#catalog"
|
||||||
className="rounded-xl border border-white/20 px-5 py-4 text-center text-sm font-bold text-white"
|
className="rounded-xl border border-white/20 px-5 py-4 text-center text-sm font-bold text-white"
|
||||||
>
|
>
|
||||||
Admin dashboard
|
Review catalog flow
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/products/products-list"
|
href="#operations"
|
||||||
className="rounded-xl border border-white/20 px-5 py-4 text-center text-sm font-bold text-white"
|
className="rounded-xl border border-white/20 px-5 py-4 text-center text-sm font-bold text-white"
|
||||||
>
|
>
|
||||||
Product admin
|
Review operations flow
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,14 +9,12 @@ 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 { hasPermission } from '../helpers/userPermissions';
|
||||||
|
|
||||||
const heroImage =
|
const heroImage =
|
||||||
'https://images.pexels.com/photos/1126728/pexels-photo-1126728.jpeg?auto=compress&cs=tinysrgb&w=1800';
|
'https://images.pexels.com/photos/1126728/pexels-photo-1126728.jpeg?auto=compress&cs=tinysrgb&w=1800';
|
||||||
|
|
||||||
const teamImage =
|
const demoCredentials = [
|
||||||
'https://images.pexels.com/photos/6169659/pexels-photo-6169659.jpeg?auto=compress&cs=tinysrgb&w=900';
|
|
||||||
|
|
||||||
const staffCredentials = [
|
|
||||||
{
|
{
|
||||||
label: 'Supplier admin',
|
label: 'Supplier admin',
|
||||||
role: 'Administrator',
|
role: 'Administrator',
|
||||||
@ -26,20 +24,28 @@ const staffCredentials = [
|
|||||||
note: 'Full access to catalog, buyer accounts, price lists, orders, samples, and fulfillment workflows.',
|
note: 'Full access to catalog, buyer accounts, price lists, orders, samples, and fulfillment workflows.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Demo user',
|
label: 'Buyer admin',
|
||||||
role: 'User',
|
role: 'Customer Buyer Admin',
|
||||||
name: 'Standard SaaS user',
|
name: 'Maria Alvarez',
|
||||||
email: 'client@hello.com',
|
email: 'maria.alvarez@harbortable.com',
|
||||||
password: 'a04a876c6d59',
|
password: 'a04a876c6d59',
|
||||||
note: 'Lightweight generated SaaS user for smoke checks outside the buyer-specific workspace.',
|
note: 'Purchasing lead with order, sample, quote, saved guide, and team queue access.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Buyer user',
|
||||||
|
role: 'Customer Buyer',
|
||||||
|
name: 'Owen Price',
|
||||||
|
email: 'owen.price@harbortable.com',
|
||||||
|
password: 'a04a876c6d59',
|
||||||
|
note: 'Restaurant buyer focused on contract catalog, reorder history, sample requests, and PO checkout.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [email, setEmail] = useState(staffCredentials[0].email);
|
const [email, setEmail] = useState(demoCredentials[0].email);
|
||||||
const [password, setPassword] = useState(staffCredentials[0].password);
|
const [password, setPassword] = useState(demoCredentials[0].password);
|
||||||
const [remember, setRemember] = useState(true);
|
const [remember, setRemember] = useState(true);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const {
|
const {
|
||||||
@ -50,7 +56,28 @@ export default function Login() {
|
|||||||
notify: notifyState,
|
notify: notifyState,
|
||||||
} = useAppSelector((state) => state.auth);
|
} = useAppSelector((state) => state.auth);
|
||||||
|
|
||||||
const title = 'B2B Distributor Portal';
|
const getReturnTo = () => {
|
||||||
|
const value = Array.isArray(router.query.returnTo)
|
||||||
|
? router.query.returnTo[0]
|
||||||
|
: router.query.returnTo;
|
||||||
|
|
||||||
|
if (value && value.startsWith('/') && !value.startsWith('//')) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!router.isReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getReturnTo().startsWith('/buyer-portal')) {
|
||||||
|
setEmail(demoCredentials[1].email);
|
||||||
|
setPassword(demoCredentials[1].password);
|
||||||
|
}
|
||||||
|
}, [router.isReady]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (token) {
|
||||||
@ -60,18 +87,31 @@ export default function Login() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUser?.id) {
|
if (currentUser?.id) {
|
||||||
if (
|
const returnTo = getReturnTo();
|
||||||
['Customer Buyer Admin', 'Customer Buyer'].includes(
|
|
||||||
currentUser.app_role?.name,
|
if (returnTo) {
|
||||||
)
|
router.push(returnTo);
|
||||||
) {
|
|
||||||
router.push('/buyer-portal');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push('/dashboard');
|
const hasSupplierAccess = hasPermission(currentUser, [
|
||||||
|
'READ_ACCOUNTS',
|
||||||
|
'READ_PRODUCTS',
|
||||||
|
'READ_ORDERS',
|
||||||
|
'READ_INVENTORY_ITEMS',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
hasPermission(currentUser, 'READ_BUYER_PORTAL') &&
|
||||||
|
!hasSupplierAccess
|
||||||
|
) {
|
||||||
|
router.push('/buyer-portal/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/dashboard/');
|
||||||
}
|
}
|
||||||
}, [currentUser?.id, router]);
|
}, [currentUser, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
@ -86,7 +126,7 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
}, [notifyState?.showNotification, notifyState?.textNotification, dispatch]);
|
}, [notifyState?.showNotification, notifyState?.textNotification, dispatch]);
|
||||||
|
|
||||||
const applyCredentials = (credentials: (typeof staffCredentials)[number]) => {
|
const applyCredentials = (credentials: (typeof demoCredentials)[number]) => {
|
||||||
setEmail(credentials.email);
|
setEmail(credentials.email);
|
||||||
setPassword(credentials.password);
|
setPassword(credentials.password);
|
||||||
setRemember(true);
|
setRemember(true);
|
||||||
@ -103,12 +143,12 @@ export default function Login() {
|
|||||||
<title>{getPageTitle('Login')}</title>
|
<title>{getPageTitle('Login')}</title>
|
||||||
<meta
|
<meta
|
||||||
name='description'
|
name='description'
|
||||||
content='Staff sign in for the Northstar Foodservice supplier and distributor portal.'
|
content='Unified sign in for the Northstar Foodservice supplier and distributor portal.'
|
||||||
/>
|
/>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<main className='min-h-screen bg-stone-50 text-slate-950'>
|
<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]'>
|
<section className='grid min-h-screen lg:grid-cols-[minmax(0,1.1fr)_500px]'>
|
||||||
<div className='relative hidden overflow-hidden lg:block'>
|
<div className='relative hidden overflow-hidden lg:block'>
|
||||||
<img
|
<img
|
||||||
src={heroImage}
|
src={heroImage}
|
||||||
@ -122,12 +162,12 @@ export default function Login() {
|
|||||||
Northstar Foodservice
|
Northstar Foodservice
|
||||||
</p>
|
</p>
|
||||||
<h1 className='mt-5 max-w-2xl text-5xl font-semibold leading-tight'>
|
<h1 className='mt-5 max-w-2xl text-5xl font-semibold leading-tight'>
|
||||||
Staff workspace for distributor operations
|
One sign-in for supplier teams and foodservice buyers
|
||||||
</h1>
|
</h1>
|
||||||
<p className='mt-5 max-w-xl text-base leading-8 text-stone-100'>
|
<p className='mt-5 max-w-xl text-base leading-8 text-stone-100'>
|
||||||
Manage account-specific pricing, product catalogs, buyer
|
Route each role into the right workspace for contract
|
||||||
orders, sample requests, saved lists, and fulfillment handoff
|
pricing, product catalogs, purchase orders, sample requests,
|
||||||
from one generated SaaS admin.
|
saved lists, and fulfillment handoff.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -154,7 +194,7 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex items-center justify-center px-6 py-10'>
|
<div className='flex items-center justify-center px-5 py-4 lg:py-2'>
|
||||||
<div className='w-full max-w-md'>
|
<div className='w-full max-w-md'>
|
||||||
<div className='flex items-center justify-between gap-4'>
|
<div className='flex items-center justify-between gap-4'>
|
||||||
<Link
|
<Link
|
||||||
@ -163,35 +203,27 @@ export default function Login() {
|
|||||||
>
|
>
|
||||||
Back to portal overview
|
Back to portal overview
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<span className='text-sm font-semibold text-slate-500'>
|
||||||
href='/buyer-login/?returnTo=%2Fbuyer-portal%2F'
|
One login for every role
|
||||||
className='text-sm font-semibold text-slate-500 hover:text-emerald-700'
|
</span>
|
||||||
>
|
|
||||||
Buyer login
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-8 overflow-hidden border border-stone-200 bg-white shadow-sm'>
|
<div className='mt-3 overflow-hidden border border-stone-200 bg-white shadow-sm'>
|
||||||
<img
|
<div className='h-1 bg-emerald-500' />
|
||||||
src={teamImage}
|
<div className='p-3 sm:p-4'>
|
||||||
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'>
|
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-emerald-700'>
|
||||||
Staff sign in
|
Unified sign in
|
||||||
</p>
|
</p>
|
||||||
<h2 className='mt-3 text-2xl font-semibold sm:text-3xl'>
|
<h2 className='mt-2 text-2xl font-semibold'>
|
||||||
Open the supplier admin
|
Open your workspace
|
||||||
</h2>
|
</h2>
|
||||||
<p className='mt-3 text-sm leading-6 text-slate-600 sm:leading-7'>
|
<p className='mt-2 text-sm leading-6 text-slate-600'>
|
||||||
Sign in to manage the operational side of the B2B
|
Use supplier or buyer credentials. The app routes each user
|
||||||
distributor portal: products, customer accounts, contract
|
into the right workspace based on role permissions.
|
||||||
pricing, orders, quotes, and sample workflows.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className='mt-4 grid gap-2 sm:mt-5 sm:gap-3'>
|
<div className='mt-3 grid gap-2'>
|
||||||
{staffCredentials.map((credentials) => {
|
{demoCredentials.map((credentials) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
email === credentials.email &&
|
email === credentials.email &&
|
||||||
password === credentials.password;
|
password === credentials.password;
|
||||||
@ -201,49 +233,65 @@ export default function Login() {
|
|||||||
key={credentials.email}
|
key={credentials.email}
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => applyCredentials(credentials)}
|
onClick={() => applyCredentials(credentials)}
|
||||||
className={`rounded-2xl border p-3 text-left transition sm:p-4 ${
|
className={`rounded-xl border px-3 py-2.5 text-left transition ${
|
||||||
isActive
|
isActive
|
||||||
? 'border-emerald-300 bg-emerald-50'
|
? 'border-emerald-300 bg-emerald-50'
|
||||||
: 'border-stone-200 bg-stone-50 hover:border-emerald-200 hover: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 className='grid gap-2 sm:grid-cols-[minmax(0,1fr)_170px] sm:items-center'>
|
||||||
<div>
|
<div className='min-w-0'>
|
||||||
<p className='text-sm font-bold text-slate-950'>
|
<div className='flex items-center gap-2'>
|
||||||
{credentials.label}
|
<p className='truncate text-sm font-bold text-slate-950'>
|
||||||
</p>
|
{credentials.label}
|
||||||
<p className='mt-1 text-xs font-semibold uppercase tracking-[0.16em] text-emerald-700'>
|
</p>
|
||||||
|
{isActive && (
|
||||||
|
<span className='rounded-full bg-emerald-600 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.12em] text-white'>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className='mt-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-emerald-700'>
|
||||||
{credentials.role}
|
{credentials.role}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className='rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-600 shadow-sm'>
|
<div className='flex min-w-0 items-center justify-between gap-2 sm:block'>
|
||||||
Use creds
|
<div className='min-w-0'>
|
||||||
|
<p className='truncate text-xs font-semibold text-slate-900 sm:hidden'>
|
||||||
|
{credentials.name}
|
||||||
|
</p>
|
||||||
|
<p className='truncate font-mono text-xs text-slate-600'>
|
||||||
|
{credentials.email}
|
||||||
|
</p>
|
||||||
|
<p className='font-mono text-xs text-slate-500'>
|
||||||
|
{credentials.password}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className='shrink-0 rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-slate-600 shadow-sm sm:hidden'>
|
||||||
|
Use
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 hidden items-center justify-between gap-2 sm:flex'>
|
||||||
|
<p className='truncate text-xs font-semibold text-slate-900'>
|
||||||
|
{credentials.name}
|
||||||
|
</p>
|
||||||
|
<span className='rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-slate-600 shadow-sm'>
|
||||||
|
Use
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<div className='mt-5 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-800'>
|
<div className='mt-4 rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-800'>
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form className='mt-6 space-y-4' onSubmit={handleSubmit}>
|
<form className='mt-4 space-y-3' onSubmit={handleSubmit}>
|
||||||
<label className='block'>
|
<label className='block'>
|
||||||
<span className='text-sm font-semibold text-slate-900'>
|
<span className='text-sm font-semibold text-slate-900'>
|
||||||
Email
|
Email
|
||||||
@ -254,7 +302,7 @@ export default function Login() {
|
|||||||
onChange={(event) => setEmail(event.target.value)}
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
type='email'
|
type='email'
|
||||||
autoComplete='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'
|
className='mt-1.5 h-11 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>
|
||||||
|
|
||||||
@ -262,7 +310,7 @@ export default function Login() {
|
|||||||
<span className='text-sm font-semibold text-slate-900'>
|
<span className='text-sm font-semibold text-slate-900'>
|
||||||
Password
|
Password
|
||||||
</span>
|
</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'>
|
<div className='mt-1.5 flex h-11 overflow-hidden rounded-xl border border-stone-300 bg-white focus-within:border-emerald-500 focus-within:ring-2 focus:ring-emerald-100'>
|
||||||
<input
|
<input
|
||||||
name='password'
|
name='password'
|
||||||
value={password}
|
value={password}
|
||||||
@ -304,38 +352,14 @@ export default function Login() {
|
|||||||
<button
|
<button
|
||||||
type='submit'
|
type='submit'
|
||||||
disabled={isFetching}
|
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'
|
className='h-11 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'}
|
{isFetching ? 'Signing in...' : 'Sign in'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -25,7 +25,6 @@ const OrdersTablesPage = () => {
|
|||||||
const [filterItems, setFilterItems] = useState([]);
|
const [filterItems, setFilterItems] = useState([]);
|
||||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||||
const [isModalActive, setIsModalActive] = useState(false);
|
const [isModalActive, setIsModalActive] = useState(false);
|
||||||
const [showTableView, setShowTableView] = useState(false);
|
|
||||||
|
|
||||||
|
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
@ -34,10 +33,10 @@ const OrdersTablesPage = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
|
||||||
const [filters] = useState([{label: 'Ordernumber', title: 'order_number'},{label: 'POnumber', title: 'po_number'},{label: 'Buyernotes', title: 'buyer_notes'},{label: 'Internalnotes', title: 'internal_notes'},
|
const [filters] = useState([{label: 'Order number', title: 'order_number'},{label: 'PO number', title: 'po_number'},{label: 'Buyer notes', title: 'buyer_notes'},{label: 'Internal notes', title: 'internal_notes'},
|
||||||
|
|
||||||
{label: 'Subtotal', title: 'subtotal', number: 'true'},{label: 'Taxtotal', title: 'tax_total', number: 'true'},{label: 'Shippingtotal', title: 'shipping_total', number: 'true'},{label: 'Ordertotal', title: 'order_total', number: 'true'},
|
{label: 'Subtotal', title: 'subtotal', number: 'true'},{label: 'Tax total', title: 'tax_total', number: 'true'},{label: 'Shipping total', title: 'shipping_total', number: 'true'},{label: 'Order total', title: 'order_total', number: 'true'},
|
||||||
{label: 'Orderedat', title: 'ordered_at', date: 'true'},{label: 'Requesteddeliverydate', title: 'requested_delivery_date', date: 'true'},{label: 'Promiseddeliverydate', title: 'promised_delivery_date', date: 'true'},
|
{label: 'Ordered at', title: 'ordered_at', date: 'true'},{label: 'Requested delivery date', title: 'requested_delivery_date', date: 'true'},{label: 'Promised delivery date', title: 'promised_delivery_date', date: 'true'},
|
||||||
|
|
||||||
|
|
||||||
{label: 'Account', title: 'account'},
|
{label: 'Account', title: 'account'},
|
||||||
@ -52,7 +51,7 @@ const OrdersTablesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
{label: 'Orderstatus', title: 'order_status', type: 'enum', options: ['draft','submitted','approved','picking','shipped','delivered','cancelled']},{label: 'Paymentterms', title: 'payment_terms', type: 'enum', options: ['net_15','net_30','net_45','prepaid']},
|
{label: 'Order status', title: 'order_status', type: 'enum', options: ['draft','submitted','approved','picking','shipped','delivered','cancelled']},{label: 'Payment terms', title: 'payment_terms', type: 'enum', options: ['net_15','net_30','net_45','prepaid']},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ORDERS');
|
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ORDERS');
|
||||||
@ -98,28 +97,28 @@ const OrdersTablesPage = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Orders')}</title>
|
<title>{getPageTitle('Purchase orders')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Orders" main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Purchase orders" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||||
|
|
||||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/orders/orders-new'} color='info' label='New Item'/>}
|
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/orders/orders-new'} color='info' label='Add order'/>}
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className={'mr-3'}
|
className={'mr-3'}
|
||||||
color='info'
|
color='info'
|
||||||
label='Filter'
|
label='Add filter'
|
||||||
onClick={addFilter}
|
onClick={addFilter}
|
||||||
/>
|
/>
|
||||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getOrdersCSV} />
|
<BaseButton className={'mr-3'} color='info' label='Export CSV' onClick={getOrdersCSV} />
|
||||||
|
|
||||||
{hasCreatePermission && (
|
{hasCreatePermission && (
|
||||||
<BaseButton
|
<BaseButton
|
||||||
color='info'
|
color='info'
|
||||||
label='Upload CSV'
|
label='Import CSV'
|
||||||
onClick={() => setIsModalActive(true)}
|
onClick={() => setIsModalActive(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -129,7 +128,7 @@ const OrdersTablesPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='md:inline-flex items-center ms-auto'>
|
<div className='md:inline-flex items-center ms-auto'>
|
||||||
<Link href={'/orders/orders-table'}>Switch to Table</Link>
|
<Link href={'/orders/orders-table'}>Table view</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</CardBox>
|
</CardBox>
|
||||||
|
|||||||
@ -25,7 +25,6 @@ const OrdersTablesPage = () => {
|
|||||||
const [filterItems, setFilterItems] = useState([]);
|
const [filterItems, setFilterItems] = useState([]);
|
||||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||||
const [isModalActive, setIsModalActive] = useState(false);
|
const [isModalActive, setIsModalActive] = useState(false);
|
||||||
const [showTableView, setShowTableView] = useState(false);
|
|
||||||
|
|
||||||
|
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
@ -34,10 +33,10 @@ const OrdersTablesPage = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
|
||||||
const [filters] = useState([{label: 'Ordernumber', title: 'order_number'},{label: 'POnumber', title: 'po_number'},{label: 'Buyernotes', title: 'buyer_notes'},{label: 'Internalnotes', title: 'internal_notes'},
|
const [filters] = useState([{label: 'Order number', title: 'order_number'},{label: 'PO number', title: 'po_number'},{label: 'Buyer notes', title: 'buyer_notes'},{label: 'Internal notes', title: 'internal_notes'},
|
||||||
|
|
||||||
{label: 'Subtotal', title: 'subtotal', number: 'true'},{label: 'Taxtotal', title: 'tax_total', number: 'true'},{label: 'Shippingtotal', title: 'shipping_total', number: 'true'},{label: 'Ordertotal', title: 'order_total', number: 'true'},
|
{label: 'Subtotal', title: 'subtotal', number: 'true'},{label: 'Tax total', title: 'tax_total', number: 'true'},{label: 'Shipping total', title: 'shipping_total', number: 'true'},{label: 'Order total', title: 'order_total', number: 'true'},
|
||||||
{label: 'Orderedat', title: 'ordered_at', date: 'true'},{label: 'Requesteddeliverydate', title: 'requested_delivery_date', date: 'true'},{label: 'Promiseddeliverydate', title: 'promised_delivery_date', date: 'true'},
|
{label: 'Ordered at', title: 'ordered_at', date: 'true'},{label: 'Requested delivery date', title: 'requested_delivery_date', date: 'true'},{label: 'Promised delivery date', title: 'promised_delivery_date', date: 'true'},
|
||||||
|
|
||||||
|
|
||||||
{label: 'Account', title: 'account'},
|
{label: 'Account', title: 'account'},
|
||||||
@ -52,7 +51,7 @@ const OrdersTablesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
{label: 'Orderstatus', title: 'order_status', type: 'enum', options: ['draft','submitted','approved','picking','shipped','delivered','cancelled']},{label: 'Paymentterms', title: 'payment_terms', type: 'enum', options: ['net_15','net_30','net_45','prepaid']},
|
{label: 'Order status', title: 'order_status', type: 'enum', options: ['draft','submitted','approved','picking','shipped','delivered','cancelled']},{label: 'Payment terms', title: 'payment_terms', type: 'enum', options: ['net_15','net_30','net_45','prepaid']},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ORDERS');
|
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ORDERS');
|
||||||
@ -98,28 +97,28 @@ const OrdersTablesPage = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Orders')}</title>
|
<title>{getPageTitle('Purchase orders table')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Orders" main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Purchase orders table" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||||
|
|
||||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/orders/orders-new'} color='info' label='New Item'/>}
|
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/orders/orders-new'} color='info' label='Add order'/>}
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className={'mr-3'}
|
className={'mr-3'}
|
||||||
color='info'
|
color='info'
|
||||||
label='Filter'
|
label='Add filter'
|
||||||
onClick={addFilter}
|
onClick={addFilter}
|
||||||
/>
|
/>
|
||||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getOrdersCSV} />
|
<BaseButton className={'mr-3'} color='info' label='Export CSV' onClick={getOrdersCSV} />
|
||||||
|
|
||||||
{hasCreatePermission && (
|
{hasCreatePermission && (
|
||||||
<BaseButton
|
<BaseButton
|
||||||
color='info'
|
color='info'
|
||||||
label='Upload CSV'
|
label='Import CSV'
|
||||||
onClick={() => setIsModalActive(true)}
|
onClick={() => setIsModalActive(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -128,7 +127,7 @@ const OrdersTablesPage = () => {
|
|||||||
<div id='delete-rows-button'></div>
|
<div id='delete-rows-button'></div>
|
||||||
|
|
||||||
<Link href={'/orders/orders-list'}>
|
<Link href={'/orders/orders-list'}>
|
||||||
Back to <span className='capitalize'>kanban</span>
|
Card view
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -25,18 +25,18 @@ const ProductsTablesPage = () => {
|
|||||||
const [filterItems, setFilterItems] = useState([]);
|
const [filterItems, setFilterItems] = useState([]);
|
||||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||||
const [isModalActive, setIsModalActive] = useState(false);
|
const [isModalActive, setIsModalActive] = useState(false);
|
||||||
const [showTableView, setShowTableView] = useState(false);
|
|
||||||
|
|
||||||
|
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
const { count } = useAppSelector((state) => state.products);
|
||||||
|
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
|
||||||
const [filters] = useState([{label: 'SKU', title: 'sku'},{label: 'Productname', title: 'product_name'},{label: 'Brand', title: 'brand'},{label: 'Shortdescription', title: 'short_description'},{label: 'Longdescription', title: 'long_description'},{label: 'Packsize', title: 'pack_size'},{label: 'Unitofmeasure', title: 'uom'},{label: 'Allergens', title: 'allergens'},{label: 'Certifications', title: 'certifications'},
|
const [filters] = useState([{label: 'SKU', title: 'sku'},{label: 'Product name', title: 'product_name'},{label: 'Brand', title: 'brand'},{label: 'Short description', title: 'short_description'},{label: 'Long description', title: 'long_description'},{label: 'Pack size', title: 'pack_size'},{label: 'Unit of measure', title: 'uom'},{label: 'Allergens', title: 'allergens'},{label: 'Certifications', title: 'certifications'},
|
||||||
{label: 'Unitspercase', title: 'units_per_case', number: 'true'},{label: 'MOQ(cases)', title: 'moq_cases', number: 'true'},
|
{label: 'Units per case', title: 'units_per_case', number: 'true'},{label: 'MOQ cases', title: 'moq_cases', number: 'true'},
|
||||||
{label: 'Unitweight(lbs)', title: 'unit_weight_lbs', number: 'true'},{label: 'Caseweight(lbs)', title: 'case_weight_lbs', number: 'true'},
|
{label: 'Unit weight lbs', title: 'unit_weight_lbs', number: 'true'},{label: 'Case weight lbs', title: 'case_weight_lbs', number: 'true'},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -44,7 +44,7 @@ const ProductsTablesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
{label: 'Temperaturezone', title: 'temperature_zone', type: 'enum', options: ['ambient','refrigerated','frozen']},{label: 'Productstatus', title: 'product_status', type: 'enum', options: ['active','discontinued','seasonal','out_of_stock']},
|
{label: 'Temperature zone', title: 'temperature_zone', type: 'enum', options: ['ambient','refrigerated','frozen']},{label: 'Product status', title: 'product_status', type: 'enum', options: ['active','discontinued','seasonal','out_of_stock']},{label: 'Sample eligible', title: 'is_sample_eligible', type: 'enum', options: ['true','false']},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_PRODUCTS');
|
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_PRODUCTS');
|
||||||
@ -64,6 +64,19 @@ const ProductsTablesPage = () => {
|
|||||||
setFilterItems([...filterItems, newItem]);
|
setFilterItems([...filterItems, newItem]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addPresetFilter = (selectedField: string, filterValue: string) => {
|
||||||
|
const newItem = {
|
||||||
|
id: uniqueId(),
|
||||||
|
fields: {
|
||||||
|
filterValue,
|
||||||
|
filterValueFrom: '',
|
||||||
|
filterValueTo: '',
|
||||||
|
selectedField,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setFilterItems([...filterItems, newItem]);
|
||||||
|
};
|
||||||
|
|
||||||
const getProductsCSV = async () => {
|
const getProductsCSV = async () => {
|
||||||
const response = await axios({url: '/products?filetype=csv', method: 'GET',responseType: 'blob'});
|
const response = await axios({url: '/products?filetype=csv', method: 'GET',responseType: 'blob'});
|
||||||
const type = response.headers['content-type']
|
const type = response.headers['content-type']
|
||||||
@ -93,35 +106,99 @@ const ProductsTablesPage = () => {
|
|||||||
<title>{getPageTitle('Products')}</title>
|
<title>{getPageTitle('Products')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Products" main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Contract catalog" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
<div className='mb-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-dark-700 dark:bg-dark-900'>
|
||||||
|
<div className='grid gap-0 lg:grid-cols-[1.4fr_1fr]'>
|
||||||
|
<div className='p-6 lg:p-8'>
|
||||||
|
<div className='mb-3 text-xs font-bold uppercase tracking-[0.24em] text-emerald-700 dark:text-emerald-300'>
|
||||||
|
Foodservice supplier catalog
|
||||||
|
</div>
|
||||||
|
<h1 className='mb-4 max-w-3xl text-3xl font-semibold tracking-normal text-slate-950 dark:text-white'>
|
||||||
|
Manage contract SKUs with the operational detail buyers expect.
|
||||||
|
</h1>
|
||||||
|
<p className='max-w-3xl text-base leading-7 text-slate-600 dark:text-slate-300'>
|
||||||
|
Keep pack size, storage zone, allergens, certifications, MOQ, sample readiness, and spec sheets visible before a restaurant buyer places a purchase order.
|
||||||
|
</p>
|
||||||
|
<div className='mt-6 flex flex-wrap gap-2'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='rounded-full border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-800 transition hover:border-emerald-300 hover:bg-emerald-100 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-100'
|
||||||
|
onClick={() => addPresetFilter('product_status', 'active')}
|
||||||
|
>
|
||||||
|
Active SKUs
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='rounded-full border border-sky-200 bg-sky-50 px-4 py-2 text-sm font-semibold text-sky-800 transition hover:border-sky-300 hover:bg-sky-100 dark:border-sky-800 dark:bg-sky-950 dark:text-sky-100'
|
||||||
|
onClick={() => addPresetFilter('temperature_zone', 'refrigerated')}
|
||||||
|
>
|
||||||
|
Refrigerated
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='rounded-full border border-cyan-200 bg-cyan-50 px-4 py-2 text-sm font-semibold text-cyan-800 transition hover:border-cyan-300 hover:bg-cyan-100 dark:border-cyan-800 dark:bg-cyan-950 dark:text-cyan-100'
|
||||||
|
onClick={() => addPresetFilter('temperature_zone', 'frozen')}
|
||||||
|
>
|
||||||
|
Frozen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='rounded-full border border-amber-200 bg-amber-50 px-4 py-2 text-sm font-semibold text-amber-800 transition hover:border-amber-300 hover:bg-amber-100 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100'
|
||||||
|
onClick={() => addPresetFilter('is_sample_eligible', 'true')}
|
||||||
|
>
|
||||||
|
Sample-ready
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='border-t border-slate-200 bg-slate-50 p-6 dark:border-dark-700 dark:bg-dark-800 lg:border-l lg:border-t-0'>
|
||||||
|
<div className='grid h-full grid-cols-2 gap-3'>
|
||||||
|
<div className='rounded-xl border border-slate-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900'>
|
||||||
|
<div className='text-xs font-bold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>Catalog SKUs</div>
|
||||||
|
<div className='mt-3 text-3xl font-semibold text-slate-950 dark:text-white'>{count || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-xl border border-slate-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900'>
|
||||||
|
<div className='text-xs font-bold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>Buyer use</div>
|
||||||
|
<div className='mt-3 text-lg font-semibold text-slate-950 dark:text-white'>Reorder + PO</div>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-xl border border-slate-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900'>
|
||||||
|
<div className='text-xs font-bold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>Controls</div>
|
||||||
|
<div className='mt-3 text-lg font-semibold text-slate-950 dark:text-white'>Allergens</div>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-xl border border-slate-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900'>
|
||||||
|
<div className='text-xs font-bold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400'>Docs</div>
|
||||||
|
<div className='mt-3 text-lg font-semibold text-slate-950 dark:text-white'>Spec sheets</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap items-center gap-3'>
|
||||||
|
|
||||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/products/products-new'} color='info' label='New Item'/>}
|
{hasCreatePermission && <BaseButton href={'/products/products-new'} color='info' label='Add SKU'/>}
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className={'mr-3'}
|
|
||||||
color='info'
|
color='info'
|
||||||
label='Filter'
|
label='Add filter'
|
||||||
onClick={addFilter}
|
onClick={addFilter}
|
||||||
/>
|
/>
|
||||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getProductsCSV} />
|
<BaseButton color='info' label='Export CSV' onClick={getProductsCSV} />
|
||||||
|
|
||||||
{hasCreatePermission && (
|
{hasCreatePermission && (
|
||||||
<BaseButton
|
<BaseButton
|
||||||
color='info'
|
color='info'
|
||||||
label='Upload CSV'
|
label='Import CSV'
|
||||||
onClick={() => setIsModalActive(true)}
|
onClick={() => setIsModalActive(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className='md:inline-flex items-center ms-auto'>
|
<div className='md:inline-flex items-center md:ml-auto'>
|
||||||
<div id='delete-rows-button'></div>
|
<div id='delete-rows-button'></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='md:inline-flex items-center ms-auto'>
|
<div className='md:inline-flex items-center'>
|
||||||
<Link href={'/products/products-table'}>Switch to Table</Link>
|
<Link className='font-semibold text-slate-700 underline-offset-4 hover:underline dark:text-slate-200' href={'/products/products-table'}>Table view</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</CardBox>
|
</CardBox>
|
||||||
|
|||||||
@ -25,7 +25,6 @@ const ProductsTablesPage = () => {
|
|||||||
const [filterItems, setFilterItems] = useState([]);
|
const [filterItems, setFilterItems] = useState([]);
|
||||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||||
const [isModalActive, setIsModalActive] = useState(false);
|
const [isModalActive, setIsModalActive] = useState(false);
|
||||||
const [showTableView, setShowTableView] = useState(false);
|
|
||||||
|
|
||||||
|
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
@ -34,9 +33,9 @@ const ProductsTablesPage = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
|
||||||
const [filters] = useState([{label: 'SKU', title: 'sku'},{label: 'Productname', title: 'product_name'},{label: 'Brand', title: 'brand'},{label: 'Shortdescription', title: 'short_description'},{label: 'Longdescription', title: 'long_description'},{label: 'Packsize', title: 'pack_size'},{label: 'Unitofmeasure', title: 'uom'},{label: 'Allergens', title: 'allergens'},{label: 'Certifications', title: 'certifications'},
|
const [filters] = useState([{label: 'SKU', title: 'sku'},{label: 'Product name', title: 'product_name'},{label: 'Brand', title: 'brand'},{label: 'Short description', title: 'short_description'},{label: 'Long description', title: 'long_description'},{label: 'Pack size', title: 'pack_size'},{label: 'Unit of measure', title: 'uom'},{label: 'Allergens', title: 'allergens'},{label: 'Certifications', title: 'certifications'},
|
||||||
{label: 'Unitspercase', title: 'units_per_case', number: 'true'},{label: 'MOQ(cases)', title: 'moq_cases', number: 'true'},
|
{label: 'Units per case', title: 'units_per_case', number: 'true'},{label: 'MOQ cases', title: 'moq_cases', number: 'true'},
|
||||||
{label: 'Unitweight(lbs)', title: 'unit_weight_lbs', number: 'true'},{label: 'Caseweight(lbs)', title: 'case_weight_lbs', number: 'true'},
|
{label: 'Unit weight lbs', title: 'unit_weight_lbs', number: 'true'},{label: 'Case weight lbs', title: 'case_weight_lbs', number: 'true'},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -44,7 +43,7 @@ const ProductsTablesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
{label: 'Temperaturezone', title: 'temperature_zone', type: 'enum', options: ['ambient','refrigerated','frozen']},{label: 'Productstatus', title: 'product_status', type: 'enum', options: ['active','discontinued','seasonal','out_of_stock']},
|
{label: 'Temperature zone', title: 'temperature_zone', type: 'enum', options: ['ambient','refrigerated','frozen']},{label: 'Product status', title: 'product_status', type: 'enum', options: ['active','discontinued','seasonal','out_of_stock']},{label: 'Sample eligible', title: 'is_sample_eligible', type: 'enum', options: ['true','false']},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_PRODUCTS');
|
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_PRODUCTS');
|
||||||
@ -93,34 +92,33 @@ const ProductsTablesPage = () => {
|
|||||||
<title>{getPageTitle('Products')}</title>
|
<title>{getPageTitle('Products')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Products" main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Catalog table" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap items-center gap-3'>
|
||||||
|
|
||||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/products/products-new'} color='info' label='New Item'/>}
|
{hasCreatePermission && <BaseButton href={'/products/products-new'} color='info' label='Add SKU'/>}
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className={'mr-3'}
|
|
||||||
color='info'
|
color='info'
|
||||||
label='Filter'
|
label='Add filter'
|
||||||
onClick={addFilter}
|
onClick={addFilter}
|
||||||
/>
|
/>
|
||||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getProductsCSV} />
|
<BaseButton color='info' label='Export CSV' onClick={getProductsCSV} />
|
||||||
|
|
||||||
{hasCreatePermission && (
|
{hasCreatePermission && (
|
||||||
<BaseButton
|
<BaseButton
|
||||||
color='info'
|
color='info'
|
||||||
label='Upload CSV'
|
label='Import CSV'
|
||||||
onClick={() => setIsModalActive(true)}
|
onClick={() => setIsModalActive(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className='md:inline-flex items-center ms-auto'>
|
<div className='md:inline-flex items-center md:ml-auto'>
|
||||||
<div id='delete-rows-button'></div>
|
<div id='delete-rows-button'></div>
|
||||||
|
|
||||||
<Link href={'/products/products-list'}>
|
<Link className='font-semibold text-slate-700 underline-offset-4 hover:underline dark:text-slate-200' href={'/products/products-list'}>
|
||||||
Back to <span className='capitalize'>card</span>
|
Card catalog
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,180 +1,481 @@
|
|||||||
import { mdiChartTimelineVariant } from '@mdi/js'
|
import * as icon from '@mdi/js';
|
||||||
import Head from 'next/head'
|
import Head from 'next/head';
|
||||||
import { uniqueId } from 'lodash';
|
import Link from 'next/link';
|
||||||
import React, { ReactElement, useState } from 'react'
|
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
|
||||||
import CardBox from '../../components/CardBox'
|
import axios from 'axios';
|
||||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
|
||||||
import SectionMain from '../../components/SectionMain'
|
|
||||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
|
||||||
import { getPageTitle } from '../../config'
|
|
||||||
import TableSample_requests from '../../components/Sample_requests/TableSample_requests'
|
|
||||||
import BaseButton from '../../components/BaseButton'
|
|
||||||
import axios from "axios";
|
|
||||||
import Link from "next/link";
|
|
||||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
|
||||||
import CardBoxModal from "../../components/CardBoxModal";
|
|
||||||
import DragDropFilePicker from "../../components/DragDropFilePicker";
|
|
||||||
import {setRefetch, uploadCsv} from '../../stores/sample_requests/sample_requestsSlice';
|
|
||||||
|
|
||||||
|
import CardBox from '../../components/CardBox';
|
||||||
|
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||||
|
import LoadingSpinner from '../../components/LoadingSpinner';
|
||||||
|
import SectionMain from '../../components/SectionMain';
|
||||||
|
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
||||||
|
import { getPageTitle } from '../../config';
|
||||||
|
import { hasPermission } from '../../helpers/userPermissions';
|
||||||
|
import { useAppSelector } from '../../stores/hooks';
|
||||||
|
|
||||||
import {hasPermission} from "../../helpers/userPermissions";
|
type UserSummary = {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountSummary = {
|
||||||
|
account_name?: string;
|
||||||
|
account_type?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LocationSummary = {
|
||||||
|
location_name?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const Sample_requestsTablesPage = () => {
|
type ProductSummary = {
|
||||||
const [filterItems, setFilterItems] = useState([]);
|
sku?: string;
|
||||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
product_name?: string;
|
||||||
const [isModalActive, setIsModalActive] = useState(false);
|
brand?: string;
|
||||||
const [showTableView, setShowTableView] = useState(false);
|
pack_size?: string;
|
||||||
|
temperature_zone?: string;
|
||||||
|
category?: {
|
||||||
|
category_name?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type SampleRequest = {
|
||||||
|
id: string;
|
||||||
|
sample_request_number: string;
|
||||||
|
sample_quantity?: number;
|
||||||
|
requested_at?: string;
|
||||||
|
needed_by?: string;
|
||||||
|
sample_status?: string;
|
||||||
|
notes?: string;
|
||||||
|
account?: AccountSummary;
|
||||||
|
location?: LocationSummary;
|
||||||
|
product?: ProductSummary;
|
||||||
|
requested_by?: UserSummary;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: 'All', value: 'all' },
|
||||||
|
{ label: 'Requested', value: 'requested' },
|
||||||
|
{ label: 'Approved', value: 'approved' },
|
||||||
|
{ label: 'Shipped', value: 'shipped' },
|
||||||
|
{ label: 'Delivered', value: 'delivered' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sampleFlow = [
|
||||||
|
'Requested by buyer',
|
||||||
|
'Sales review',
|
||||||
|
'Sample shipped',
|
||||||
|
'Chef feedback',
|
||||||
|
'Quote or PO follow-up',
|
||||||
|
];
|
||||||
|
|
||||||
|
const generatedSeedNames = [
|
||||||
|
'Ada Lovelace',
|
||||||
|
'Alan Turing',
|
||||||
|
'Grace Hopper',
|
||||||
|
'Marie Curie',
|
||||||
|
];
|
||||||
|
|
||||||
|
const formatDate = (value?: string) => {
|
||||||
|
if (!value) {
|
||||||
|
return 'Not set';
|
||||||
|
}
|
||||||
|
|
||||||
|
return dateFormatter.format(new Date(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatUserName = (user?: UserSummary) => {
|
||||||
|
if (!user) {
|
||||||
|
return 'Unassigned';
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = [user.firstName, user.lastName].filter(Boolean).join(' ');
|
||||||
|
return name || user.email || 'Unassigned';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isGeneratedSeedRequest = (item: SampleRequest) => {
|
||||||
|
const searchableText = [
|
||||||
|
item.sample_request_number,
|
||||||
|
item.notes,
|
||||||
|
item.account?.account_name,
|
||||||
|
item.account?.account_type,
|
||||||
|
item.location?.location_name,
|
||||||
|
item.location?.city,
|
||||||
|
item.location?.state,
|
||||||
|
item.product?.sku,
|
||||||
|
item.product?.product_name,
|
||||||
|
item.product?.brand,
|
||||||
|
item.product?.pack_size,
|
||||||
|
item.requested_by?.firstName,
|
||||||
|
item.requested_by?.lastName,
|
||||||
|
item.requested_by?.email,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
return generatedSeedNames.some((name) => searchableText.includes(name));
|
||||||
|
};
|
||||||
|
|
||||||
|
const humanizeStatus = (status?: string) => {
|
||||||
|
if (!status) {
|
||||||
|
return 'Unassigned';
|
||||||
|
}
|
||||||
|
|
||||||
|
return status
|
||||||
|
.split('_')
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusClass = (status?: string) => {
|
||||||
|
if (['approved', 'delivered'].includes(status || '')) {
|
||||||
|
return 'border-emerald-200 bg-emerald-50 text-emerald-800';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'shipped') {
|
||||||
|
return 'border-blue-200 bg-blue-50 text-blue-800';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'requested') {
|
||||||
|
return 'border-amber-200 bg-amber-50 text-amber-800';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['declined', 'cancelled'].includes(status || '')) {
|
||||||
|
return 'border-rose-200 bg-rose-50 text-rose-800';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'border-slate-200 bg-slate-50 text-slate-700';
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusPill = ({ status }: { status?: string }) => (
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-bold ${getStatusClass(
|
||||||
|
status,
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{humanizeStatus(status)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SampleRequestsListPage = () => {
|
||||||
const { currentUser } = useAppSelector((state) => state.auth);
|
const { currentUser } = useAppSelector((state) => state.auth);
|
||||||
|
const [sampleRequests, setSampleRequests] = useState<SampleRequest[]>([]);
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState('all');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const hasCreatePermission =
|
||||||
|
!!currentUser && hasPermission(currentUser, 'CREATE_SAMPLE_REQUESTS');
|
||||||
|
|
||||||
|
const loadSampleRequests = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setErrorMessage('');
|
||||||
|
|
||||||
const [filters] = useState([{label: 'Samplerequestnumber', title: 'sample_request_number'},{label: 'Notes', title: 'notes'},
|
try {
|
||||||
{label: 'Samplequantity', title: 'sample_quantity', number: 'true'},
|
const { data } = await axios.get('/sample_requests', {
|
||||||
|
params: { page: 0, limit: 30 },
|
||||||
{label: 'Requestedat', title: 'requested_at', date: 'true'},{label: 'Neededby', title: 'needed_by', date: 'true'},
|
});
|
||||||
|
const businessRows = (data.rows || []).filter(
|
||||||
|
(item: SampleRequest) => !isGeneratedSeedRequest(item),
|
||||||
{label: 'Account', title: 'account'},
|
);
|
||||||
|
setSampleRequests(businessRows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load sample requests', error);
|
||||||
{label: 'Location', title: 'location'},
|
setErrorMessage('Unable to load sample requests.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
{label: 'Requestedby', title: 'requested_by'},
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{label: 'Product', title: 'product'},
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{label: 'Samplestatus', title: 'sample_status', type: 'enum', options: ['requested','approved','shipped','delivered','declined','cancelled']},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_SAMPLE_REQUESTS');
|
|
||||||
|
|
||||||
|
|
||||||
const addFilter = () => {
|
useEffect(() => {
|
||||||
const newItem = {
|
if (!currentUser) {
|
||||||
id: uniqueId(),
|
return;
|
||||||
fields: {
|
}
|
||||||
filterValue: '',
|
|
||||||
filterValueFrom: '',
|
|
||||||
filterValueTo: '',
|
|
||||||
selectedField: '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
newItem.fields.selectedField = filters[0].title;
|
|
||||||
setFilterItems([...filterItems, newItem]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSample_requestsCSV = async () => {
|
void loadSampleRequests();
|
||||||
const response = await axios({url: '/sample_requests?filetype=csv', method: 'GET',responseType: 'blob'});
|
}, [currentUser?.id]);
|
||||||
const type = response.headers['content-type']
|
|
||||||
const blob = new Blob([response.data], { type: type })
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = window.URL.createObjectURL(blob)
|
|
||||||
link.download = 'sample_requestsCSV.csv'
|
|
||||||
link.click()
|
|
||||||
};
|
|
||||||
|
|
||||||
const onModalConfirm = async () => {
|
const filteredRequests = useMemo(() => {
|
||||||
if (!csvFile) return;
|
if (selectedStatus === 'all') {
|
||||||
await dispatch(uploadCsv(csvFile));
|
return sampleRequests;
|
||||||
dispatch(setRefetch(true));
|
}
|
||||||
setCsvFile(null);
|
|
||||||
setIsModalActive(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onModalCancel = () => {
|
return sampleRequests.filter(
|
||||||
setCsvFile(null);
|
(item) => item.sample_status === selectedStatus,
|
||||||
setIsModalActive(false);
|
);
|
||||||
};
|
}, [sampleRequests, selectedStatus]);
|
||||||
|
|
||||||
|
const requestedCount = sampleRequests.filter(
|
||||||
|
(item) => item.sample_status === 'requested',
|
||||||
|
).length;
|
||||||
|
const approvedCount = sampleRequests.filter(
|
||||||
|
(item) => item.sample_status === 'approved',
|
||||||
|
).length;
|
||||||
|
const deliveredCount = sampleRequests.filter(
|
||||||
|
(item) => item.sample_status === 'delivered',
|
||||||
|
).length;
|
||||||
|
const activeCount = sampleRequests.filter(
|
||||||
|
(item) => !['delivered', 'declined', 'cancelled'].includes(item.sample_status || ''),
|
||||||
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Sample_requests')}</title>
|
<title>{getPageTitle('Sample requests')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Sample_requests" main>
|
<SectionTitleLineWithButton
|
||||||
{''}
|
icon={icon.mdiPackageVariant}
|
||||||
</SectionTitleLineWithButton>
|
title='Sample requests'
|
||||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
main
|
||||||
|
>
|
||||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/sample_requests/sample_requests-new'} color='info' label='New Item'/>}
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
|
||||||
<BaseButton
|
|
||||||
className={'mr-3'}
|
|
||||||
color='info'
|
|
||||||
label='Filter'
|
|
||||||
onClick={addFilter}
|
|
||||||
/>
|
|
||||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getSample_requestsCSV} />
|
|
||||||
|
|
||||||
{hasCreatePermission && (
|
{hasCreatePermission && (
|
||||||
<BaseButton
|
<Link
|
||||||
color='info'
|
href='/sample_requests/sample_requests-new'
|
||||||
label='Upload CSV'
|
className='inline-flex h-10 items-center rounded-xl bg-slate-950 px-4 text-sm font-semibold text-white hover:bg-slate-800'
|
||||||
onClick={() => setIsModalActive(true)}
|
>
|
||||||
/>
|
New sample
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
<Link
|
||||||
<div className='md:inline-flex items-center ms-auto'>
|
href='/sample_requests/sample_requests-table'
|
||||||
<div id='delete-rows-button'></div>
|
className='inline-flex h-10 items-center rounded-xl border border-slate-200 bg-white px-4 text-sm font-semibold text-slate-700 hover:bg-slate-50'
|
||||||
</div>
|
>
|
||||||
|
Table view
|
||||||
<div className='md:inline-flex items-center ms-auto'>
|
</Link>
|
||||||
<Link href={'/sample_requests/sample_requests-table'}>Switch to Table</Link>
|
</div>
|
||||||
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
|
<CardBox className='mb-6 border border-slate-200 bg-white shadow-sm'>
|
||||||
|
<div className='grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]'>
|
||||||
|
<div>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-amber-700'>
|
||||||
|
Chef evaluation queue
|
||||||
|
</p>
|
||||||
|
<h1 className='mt-3 text-3xl font-semibold text-slate-950'>
|
||||||
|
Track samples from buyer request to menu decision
|
||||||
|
</h1>
|
||||||
|
<p className='mt-3 max-w-3xl text-sm leading-7 text-slate-600'>
|
||||||
|
Foodservice suppliers use samples to move chefs from curiosity
|
||||||
|
to a quote, LTO, banquet menu, or recurring PO. This queue keeps
|
||||||
|
product, buyer, ship-to, timing, and tasting notes together.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-1'>
|
||||||
|
<div className='rounded-xl border border-amber-200 bg-amber-50 p-4'>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.16em] text-amber-700'>
|
||||||
|
Active samples
|
||||||
|
</p>
|
||||||
|
<p className='mt-2 text-2xl font-semibold text-slate-950'>
|
||||||
|
{activeCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-xl border border-emerald-200 bg-emerald-50 p-4'>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.16em] text-emerald-700'>
|
||||||
|
Ready for follow-up
|
||||||
|
</p>
|
||||||
|
<p className='mt-2 text-2xl font-semibold text-slate-950'>
|
||||||
|
{approvedCount + deliveredCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4'>
|
||||||
|
<div className='rounded-xl bg-slate-50 p-4'>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.16em] text-slate-400'>
|
||||||
|
Total requests
|
||||||
|
</p>
|
||||||
|
<p className='mt-2 text-2xl font-semibold text-slate-950'>
|
||||||
|
{sampleRequests.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-xl bg-amber-50 p-4'>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.16em] text-amber-700'>
|
||||||
|
Requested
|
||||||
|
</p>
|
||||||
|
<p className='mt-2 text-2xl font-semibold text-slate-950'>
|
||||||
|
{requestedCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-xl bg-emerald-50 p-4'>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.16em] text-emerald-700'>
|
||||||
|
Approved
|
||||||
|
</p>
|
||||||
|
<p className='mt-2 text-2xl font-semibold text-slate-950'>
|
||||||
|
{approvedCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-xl bg-blue-50 p-4'>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.16em] text-blue-700'>
|
||||||
|
Delivered
|
||||||
|
</p>
|
||||||
|
<p className='mt-2 text-2xl font-semibold text-slate-950'>
|
||||||
|
{deliveredCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
|
|
||||||
<TableSample_requests
|
{errorMessage && (
|
||||||
filterItems={filterItems}
|
<div className='mb-5 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm font-semibold text-rose-800'>
|
||||||
setFilterItems={setFilterItems}
|
{errorMessage}
|
||||||
filters={filters}
|
</div>
|
||||||
showGrid={false}
|
)}
|
||||||
/>
|
|
||||||
|
<div className='mb-5 flex flex-wrap gap-2'>
|
||||||
|
{statusOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type='button'
|
||||||
|
onClick={() => setSelectedStatus(option.value)}
|
||||||
|
className={`rounded-full border px-4 py-2 text-sm font-semibold transition ${
|
||||||
|
selectedStatus === option.value
|
||||||
|
? 'border-emerald-500 bg-emerald-500 text-white'
|
||||||
|
: 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_340px]'>
|
||||||
|
<div className='grid gap-4 lg:grid-cols-2'>
|
||||||
|
{isLoading && (
|
||||||
|
<div className='col-span-full flex h-40 items-center justify-center rounded-2xl border border-slate-200 bg-white'>
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading &&
|
||||||
|
filteredRequests.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.id}
|
||||||
|
href={`/sample_requests/sample_requests-view/?id=${item.id}`}
|
||||||
|
className='group block h-full overflow-hidden rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:-translate-y-0.5 hover:border-amber-200 hover:shadow-md'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
|
||||||
|
<div className='min-w-0'>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.18em] text-amber-700'>
|
||||||
|
{item.sample_request_number}
|
||||||
|
</p>
|
||||||
|
<h2 className='mt-2 truncate text-xl font-semibold text-slate-950'>
|
||||||
|
{item.product?.product_name || 'Sample product'}
|
||||||
|
</h2>
|
||||||
|
<p className='mt-1 text-sm text-slate-500'>
|
||||||
|
{item.product?.sku || 'No SKU'} -{' '}
|
||||||
|
{item.product?.pack_size || 'Pack pending'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StatusPill status={item.sample_status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-5 grid gap-3 rounded-xl bg-slate-50 p-4 text-sm text-slate-600 sm:grid-cols-2'>
|
||||||
|
<div>
|
||||||
|
<p className='text-xs uppercase tracking-[0.14em] text-slate-400'>
|
||||||
|
Buyer account
|
||||||
|
</p>
|
||||||
|
<p className='mt-1 font-semibold text-slate-950'>
|
||||||
|
{item.account?.account_name || 'Unassigned'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='text-xs uppercase tracking-[0.14em] text-slate-400'>
|
||||||
|
Needed by
|
||||||
|
</p>
|
||||||
|
<p className='mt-1 font-semibold text-slate-950'>
|
||||||
|
{formatDate(item.needed_by)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='text-xs uppercase tracking-[0.14em] text-slate-400'>
|
||||||
|
Ship-to
|
||||||
|
</p>
|
||||||
|
<p className='mt-1 font-semibold text-slate-950'>
|
||||||
|
{item.location?.location_name || 'Not selected'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='text-xs uppercase tracking-[0.14em] text-slate-400'>
|
||||||
|
Quantity
|
||||||
|
</p>
|
||||||
|
<p className='mt-1 font-semibold text-slate-950'>
|
||||||
|
{item.sample_quantity || 0} unit(s)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className='mt-4 line-clamp-2 text-sm leading-6 text-slate-600'>
|
||||||
|
{item.notes || 'No tasting notes yet.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className='mt-5 flex flex-col items-start gap-2 border-t border-slate-100 pt-4 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-3'>
|
||||||
|
<span className='font-semibold text-slate-500'>
|
||||||
|
Requested by {formatUserName(item.requested_by)}
|
||||||
|
</span>
|
||||||
|
<span className='font-bold text-emerald-700 group-hover:text-emerald-800'>
|
||||||
|
Review
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!isLoading && !filteredRequests.length && (
|
||||||
|
<div className='col-span-full rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center text-sm text-slate-500'>
|
||||||
|
No sample requests match this status.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className='rounded-2xl border border-slate-200 bg-slate-950 p-5 text-white shadow-sm'>
|
||||||
|
<p className='text-xs font-semibold uppercase tracking-[0.22em] text-emerald-300'>
|
||||||
|
Workflow
|
||||||
|
</p>
|
||||||
|
<h3 className='mt-3 text-2xl font-semibold'>
|
||||||
|
Sample-to-order path
|
||||||
|
</h3>
|
||||||
|
<div className='mt-5 space-y-3'>
|
||||||
|
{sampleFlow.map((step, index) => (
|
||||||
|
<div
|
||||||
|
key={step}
|
||||||
|
className='flex items-center gap-3 rounded-xl border border-white/10 bg-white/5 p-3'
|
||||||
|
>
|
||||||
|
<span className='flex h-8 w-8 items-center justify-center rounded-full bg-emerald-400 text-sm font-bold text-slate-950'>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span className='text-sm font-semibold text-slate-100'>
|
||||||
|
{step}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className='mt-5 rounded-xl border border-white/10 bg-white/5 p-4 text-sm leading-6 text-slate-300'>
|
||||||
|
A strong sample workflow gives sales and operations one place to
|
||||||
|
see what was requested, why it matters, and when the buyer needs a
|
||||||
|
decision.
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
<CardBoxModal
|
|
||||||
title='Upload CSV'
|
|
||||||
buttonColor='info'
|
|
||||||
buttonLabel={'Confirm'}
|
|
||||||
// buttonLabel={false ? 'Deleting...' : 'Confirm'}
|
|
||||||
isActive={isModalActive}
|
|
||||||
onConfirm={onModalConfirm}
|
|
||||||
onCancel={onModalCancel}
|
|
||||||
>
|
|
||||||
<DragDropFilePicker
|
|
||||||
file={csvFile}
|
|
||||||
setFile={setCsvFile}
|
|
||||||
formats={'.csv'}
|
|
||||||
/>
|
|
||||||
</CardBoxModal>
|
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
Sample_requestsTablesPage.getLayout = function getLayout(page: ReactElement) {
|
SampleRequestsListPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<LayoutAuthenticated
|
<LayoutAuthenticated permission='READ_SAMPLE_REQUESTS'>
|
||||||
|
{page}
|
||||||
permission={'READ_SAMPLE_REQUESTS'}
|
</LayoutAuthenticated>
|
||||||
|
);
|
||||||
>
|
};
|
||||||
{page}
|
|
||||||
</LayoutAuthenticated>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Sample_requestsTablesPage
|
export default SampleRequestsListPage;
|
||||||
|
|||||||
@ -34,10 +34,10 @@ const Sample_requestsTablesPage = () => {
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
|
||||||
const [filters] = useState([{label: 'Samplerequestnumber', title: 'sample_request_number'},{label: 'Notes', title: 'notes'},
|
const [filters] = useState([{label: 'Sample request #', title: 'sample_request_number'},{label: 'Tasting notes', title: 'notes'},
|
||||||
{label: 'Samplequantity', title: 'sample_quantity', number: 'true'},
|
{label: 'Sample quantity', title: 'sample_quantity', number: 'true'},
|
||||||
|
|
||||||
{label: 'Requestedat', title: 'requested_at', date: 'true'},{label: 'Neededby', title: 'needed_by', date: 'true'},
|
{label: 'Requested at', title: 'requested_at', date: 'true'},{label: 'Needed by', title: 'needed_by', date: 'true'},
|
||||||
|
|
||||||
|
|
||||||
{label: 'Account', title: 'account'},
|
{label: 'Account', title: 'account'},
|
||||||
@ -48,7 +48,7 @@ const Sample_requestsTablesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
{label: 'Requestedby', title: 'requested_by'},
|
{label: 'Requested by', title: 'requested_by'},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -56,7 +56,7 @@ const Sample_requestsTablesPage = () => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
{label: 'Samplestatus', title: 'sample_status', type: 'enum', options: ['requested','approved','shipped','delivered','declined','cancelled']},
|
{label: 'Sample status', title: 'sample_status', type: 'enum', options: ['requested','approved','shipped','delivered','declined','cancelled']},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_SAMPLE_REQUESTS');
|
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_SAMPLE_REQUESTS');
|
||||||
@ -102,28 +102,28 @@ const Sample_requestsTablesPage = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Sample_requests')}</title>
|
<title>{getPageTitle('Sample request table')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Sample_requests" main>
|
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Sample request table" main>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
|
||||||
|
|
||||||
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/sample_requests/sample_requests-new'} color='info' label='New Item'/>}
|
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/sample_requests/sample_requests-new'} color='info' label='New sample'/>}
|
||||||
|
|
||||||
<BaseButton
|
<BaseButton
|
||||||
className={'mr-3'}
|
className={'mr-3'}
|
||||||
color='info'
|
color='info'
|
||||||
label='Filter'
|
label='Add filter'
|
||||||
onClick={addFilter}
|
onClick={addFilter}
|
||||||
/>
|
/>
|
||||||
<BaseButton className={'mr-3'} color='info' label='Download CSV' onClick={getSample_requestsCSV} />
|
<BaseButton className={'mr-3'} color='info' label='Export CSV' onClick={getSample_requestsCSV} />
|
||||||
|
|
||||||
{hasCreatePermission && (
|
{hasCreatePermission && (
|
||||||
<BaseButton
|
<BaseButton
|
||||||
color='info'
|
color='info'
|
||||||
label='Upload CSV'
|
label='Import CSV'
|
||||||
onClick={() => setIsModalActive(true)}
|
onClick={() => setIsModalActive(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -132,7 +132,7 @@ const Sample_requestsTablesPage = () => {
|
|||||||
<div id='delete-rows-button'></div>
|
<div id='delete-rows-button'></div>
|
||||||
|
|
||||||
<Link href={'/sample_requests/sample_requests-list'}>
|
<Link href={'/sample_requests/sample_requests-list'}>
|
||||||
Back to <span className='capitalize'>kanban</span>
|
Back to workflow
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user