diff --git a/backend/src/db/seeders/20260507130000-buyer-portal-demo-data.js b/backend/src/db/seeders/20260507130000-buyer-portal-demo-data.js new file mode 100644 index 0000000..3667688 --- /dev/null +++ b/backend/src/db/seeders/20260507130000-buyer-portal-demo-data.js @@ -0,0 +1,1783 @@ +'use strict'; + +const bcrypt = require('bcrypt'); +const config = require('../../config'); +const db = require('../models'); + +const IDS = { + users: { + buyerAdmin: '70000000-0000-4000-8000-000000000001', + buyer: '70000000-0000-4000-8000-000000000002', + salesRep: '70000000-0000-4000-8000-000000000003', + }, + accounts: { + harbor: '71000000-0000-4000-8000-000000000001', + cedar: '71000000-0000-4000-8000-000000000002', + seabrook: '71000000-0000-4000-8000-000000000003', + }, + locations: { + harborDowntown: '72000000-0000-4000-8000-000000000001', + harborRiverNorth: '72000000-0000-4000-8000-000000000002', + cedarKitchen: '72000000-0000-4000-8000-000000000003', + seabrookMidtown: '72000000-0000-4000-8000-000000000004', + seabrookWaterfront: '72000000-0000-4000-8000-000000000005', + }, + contacts: { + maria: '73000000-0000-4000-8000-000000000001', + owen: '73000000-0000-4000-8000-000000000002', + dana: '73000000-0000-4000-8000-000000000003', + priya: '73000000-0000-4000-8000-000000000004', + luca: '73000000-0000-4000-8000-000000000005', + }, + categories: { + dairy: '74000000-0000-4000-8000-000000000001', + pantry: '74000000-0000-4000-8000-000000000002', + proteins: '74000000-0000-4000-8000-000000000003', + frozen: '74000000-0000-4000-8000-000000000004', + }, + products: { + burrata: '75000000-0000-4000-8000-000000000001', + ricottaSalata: '75000000-0000-4000-8000-000000000002', + chiliCrunch: '75000000-0000-4000-8000-000000000003', + demiGlace: '75000000-0000-4000-8000-000000000004', + duckConfit: '75000000-0000-4000-8000-000000000005', + bloodOrangeSorbet: '75000000-0000-4000-8000-000000000006', + fetaCrumble: '75000000-0000-4000-8000-000000000007', + }, + priceLists: { + harbor: '76000000-0000-4000-8000-000000000001', + cedar: '76000000-0000-4000-8000-000000000002', + seabrook: '76000000-0000-4000-8000-000000000003', + }, + accountPriceLists: { + harbor: '77000000-0000-4000-8000-000000000001', + cedar: '77000000-0000-4000-8000-000000000002', + seabrook: '77000000-0000-4000-8000-000000000003', + }, + priceListItems: { + harborBurrata: '78000000-0000-4000-8000-000000000001', + harborRicotta: '78000000-0000-4000-8000-000000000002', + harborChili: '78000000-0000-4000-8000-000000000003', + harborDemi: '78000000-0000-4000-8000-000000000004', + harborDuck: '78000000-0000-4000-8000-000000000005', + harborSorbet: '78000000-0000-4000-8000-000000000006', + harborFeta: '78000000-0000-4000-8000-000000000007', + cedarBurrata: '78000000-0000-4000-8000-000000000008', + cedarRicotta: '78000000-0000-4000-8000-000000000009', + cedarChili: '78000000-0000-4000-8000-000000000010', + cedarDemi: '78000000-0000-4000-8000-000000000011', + cedarDuck: '78000000-0000-4000-8000-000000000012', + cedarSorbet: '78000000-0000-4000-8000-000000000013', + cedarFeta: '78000000-0000-4000-8000-000000000014', + seabrookBurrata: '78000000-0000-4000-8000-000000000015', + seabrookRicotta: '78000000-0000-4000-8000-000000000016', + seabrookChili: '78000000-0000-4000-8000-000000000017', + seabrookDemi: '78000000-0000-4000-8000-000000000018', + seabrookDuck: '78000000-0000-4000-8000-000000000019', + seabrookSorbet: '78000000-0000-4000-8000-000000000020', + seabrookFeta: '78000000-0000-4000-8000-000000000021', + }, + quotes: { + harbor: '79000000-0000-4000-8000-000000000001', + cedar: '79000000-0000-4000-8000-000000000002', + seabrook: '79000000-0000-4000-8000-000000000003', + }, + quoteItems: { + harborBurrata: '79100000-0000-4000-8000-000000000001', + harborChili: '79100000-0000-4000-8000-000000000002', + cedarSorbet: '79100000-0000-4000-8000-000000000003', + seabrookDuck: '79100000-0000-4000-8000-000000000004', + seabrookDemi: '79100000-0000-4000-8000-000000000005', + }, + sampleRequests: { + harbor: '79200000-0000-4000-8000-000000000001', + cedar: '79200000-0000-4000-8000-000000000002', + seabrook: '79200000-0000-4000-8000-000000000003', + }, + savedLists: { + harborWeekend: '79210000-0000-4000-8000-000000000001', + harborChefCounter: '79210000-0000-4000-8000-000000000002', + seabrookBanquet: '79210000-0000-4000-8000-000000000003', + }, + savedListItems: { + harborWeekendBurrata: '79220000-0000-4000-8000-000000000001', + harborWeekendChili: '79220000-0000-4000-8000-000000000002', + harborWeekendFeta: '79220000-0000-4000-8000-000000000003', + harborChefCounterDuck: '79220000-0000-4000-8000-000000000004', + harborChefCounterSorbet: '79220000-0000-4000-8000-000000000005', + seabrookBanquetDemi: '79220000-0000-4000-8000-000000000006', + seabrookBanquetBurrata: '79220000-0000-4000-8000-000000000007', + }, + orders: { + harborDelivered: '79300000-0000-4000-8000-000000000001', + harborSubmitted: '79300000-0000-4000-8000-000000000002', + seabrookDelivered: '79300000-0000-4000-8000-000000000003', + }, + orderItems: { + harborDeliveredBurrata: '79400000-0000-4000-8000-000000000001', + harborDeliveredChili: '79400000-0000-4000-8000-000000000002', + harborDeliveredFeta: '79400000-0000-4000-8000-000000000003', + harborSubmittedDuck: '79400000-0000-4000-8000-000000000004', + harborSubmittedSorbet: '79400000-0000-4000-8000-000000000005', + seabrookDeliveredDemi: '79400000-0000-4000-8000-000000000006', + seabrookDeliveredBurrata: '79400000-0000-4000-8000-000000000007', + }, +}; + +function daysFromNow(offset, hour = 14) { + const date = new Date(); + date.setUTCHours(hour, 0, 0, 0); + date.setUTCDate(date.getUTCDate() + offset); + return date; +} + +async function upsertRecord(model, id, values, transaction) { + const existing = await model.findByPk(id, { transaction, paranoid: false }); + + if (existing) { + try { + await existing.update(values, { transaction }); + return existing; + } catch (error) { + console.error( + 'Demo seed update failed:', + model.name, + id, + error.parent?.detail || error.parent?.message || error.message, + values, + ); + throw error; + } + } + + try { + return await model.create({ id, ...values }, { transaction }); + } catch (error) { + console.error( + 'Demo seed create failed:', + model.name, + id, + error.parent?.detail || error.parent?.message || error.message, + values, + ); + throw error; + } +} + +async function grantPermission(roleName, permissionName, transaction) { + const role = await db.roles.findOne({ + where: { + name: roleName, + }, + transaction, + }); + const permission = await db.permissions.findOne({ + where: { + name: permissionName, + }, + transaction, + }); + + if (!role || !permission) { + throw new Error( + `Missing role or permission: ${roleName} / ${permissionName}`, + ); + } + + await db.sequelize.query( + ` + INSERT INTO "rolesPermissionsPermissions" + ("createdAt", "updatedAt", "roles_permissionsId", "permissionId") + VALUES + (:createdAt, :updatedAt, :roleId, :permissionId) + ON CONFLICT ("roles_permissionsId", "permissionId") DO NOTHING + `, + { + replacements: { + createdAt: new Date(), + updatedAt: new Date(), + roleId: role.id, + permissionId: permission.id, + }, + transaction, + }, + ); +} + +module.exports = { + async up() { + const passwordHash = bcrypt.hashSync( + config.user_pass, + config.bcrypt.saltRounds, + ); + + await db.sequelize.transaction(async (transaction) => { + await grantPermission( + 'Customer Buyer Admin', + 'CREATE_ORDERS', + transaction, + ); + await grantPermission( + 'Customer Buyer Admin', + 'CREATE_ORDER_ITEMS', + transaction, + ); + await grantPermission('Customer Buyer', 'CREATE_ORDERS', transaction); + await grantPermission( + 'Customer Buyer', + 'CREATE_ORDER_ITEMS', + transaction, + ); + + const adminUser = await db.users.findOne({ + where: { + email: config.admin_email, + }, + transaction, + }); + + const buyerAdminRole = await db.roles.findOne({ + where: { + name: 'Customer Buyer Admin', + }, + transaction, + }); + const buyerRole = await db.roles.findOne({ + where: { + name: 'Customer Buyer', + }, + transaction, + }); + const salesRepRole = await db.roles.findOne({ + where: { + name: 'Sales Representative', + }, + transaction, + }); + + if (!adminUser || !buyerAdminRole || !buyerRole || !salesRepRole) { + throw new Error('Required seed references were not found.'); + } + + await upsertRecord( + db.users, + IDS.users.salesRep, + { + firstName: 'Nina', + lastName: 'Shah', + phoneNumber: '312-555-0188', + email: 'nina.shah@northstar-specialty.com', + disabled: false, + password: passwordHash, + emailVerified: true, + provider: config.providers.LOCAL, + app_roleId: salesRepRole.id, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.users, + IDS.users.buyerAdmin, + { + firstName: 'Maria', + lastName: 'Alvarez', + phoneNumber: '312-555-0141', + email: 'maria.alvarez@harbortable.com', + disabled: false, + password: passwordHash, + emailVerified: true, + provider: config.providers.LOCAL, + app_roleId: buyerAdminRole.id, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.users, + IDS.users.buyer, + { + firstName: 'Owen', + lastName: 'Price', + phoneNumber: '312-555-0170', + email: 'owen.price@harbortable.com', + disabled: false, + password: passwordHash, + emailVerified: true, + provider: config.providers.LOCAL, + app_roleId: buyerRole.id, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.product_categories, + IDS.categories.dairy, + { + category_name: 'Cultured Dairy & Cheese', + category_description: + 'Imported cheeses, cultured dairy, and finishing ingredients for menu differentiation.', + is_active: true, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.product_categories, + IDS.categories.pantry, + { + category_name: 'Chef Pantry Essentials', + category_description: + 'High-flavor pantry shortcuts for finishing, sauce work, and premium mise en place.', + is_active: true, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.product_categories, + IDS.categories.proteins, + { + category_name: 'Prepared Proteins', + category_description: + 'Labor-saving proteins sized for banquets, tasting menus, and high-volume service.', + is_active: true, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.product_categories, + IDS.categories.frozen, + { + category_name: 'Frozen Desserts', + category_description: + 'Premium frozen desserts ready for plated desserts, bars, and banqueting programs.', + is_active: true, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.products, + IDS.products.burrata, + { + sku: 'NS-BUR-2X1K', + product_name: 'Burrata di Puglia', + brand: 'Northstar Reserve', + short_description: + 'Hand-pulled burrata with a creamy stracciatella center for share plates and pizza finishing.', + long_description: + 'Creamy Italian burrata packed in foodservice-ready tubs. Ideal for antipasti, burrata boards, and seasonal tomato programs.', + pack_size: '2 x 1 kg tubs', + units_per_case: 2, + unit_weight_lbs: 2.2, + case_weight_lbs: 4.4, + uom: 'case', + moq_cases: 2, + temperature_zone: 'refrigerated', + product_status: 'active', + allergens: 'Milk', + certifications: 'Vegetarian', + is_sample_eligible: true, + categoryId: IDS.categories.dairy, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.products, + IDS.products.ricottaSalata, + { + sku: 'NS-RIC-6X500', + product_name: 'Wildflower Ricotta Salata', + brand: 'Northstar Reserve', + short_description: + 'Bright, salty ricotta salata wedges for salads, pasta, and vegetable dishes.', + long_description: + 'Firm ricotta salata with a clean sheep milk finish. Pre-sized wedges reduce prep and improve consistency.', + pack_size: '6 x 500 g wedges', + units_per_case: 6, + unit_weight_lbs: 1.1, + case_weight_lbs: 6.6, + uom: 'case', + moq_cases: 1, + temperature_zone: 'refrigerated', + product_status: 'active', + allergens: 'Milk', + certifications: 'Vegetarian, Gluten-Free', + is_sample_eligible: true, + categoryId: IDS.categories.dairy, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.products, + IDS.products.chiliCrunch, + { + sku: 'NS-CHI-12X16', + product_name: 'Calabrian Chili Crunch', + brand: 'Northstar Pantry', + short_description: + 'Sweet heat chili condiment for pizza, roasted vegetables, sandwiches, and brunch menus.', + long_description: + 'Texture-driven chili crisp made with Calabrian peppers, shallots, and toasted garlic. Works across breakfast, lunch, and dinner menus.', + pack_size: '12 x 16 oz jars', + units_per_case: 12, + unit_weight_lbs: 1, + case_weight_lbs: 12, + uom: 'case', + moq_cases: 3, + temperature_zone: 'ambient', + product_status: 'active', + allergens: 'None', + certifications: 'Vegan, Gluten-Free', + is_sample_eligible: true, + categoryId: IDS.categories.pantry, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.products, + IDS.products.demiGlace, + { + sku: 'NS-DEM-6X1L', + product_name: 'Porcini Truffle Demi-Glace', + brand: 'Northstar Pantry', + short_description: + 'Rich ready-to-finish demi-glace with porcini depth and a subtle black truffle note.', + long_description: + 'Chef-finished demi-glace for steak programs, short rib reductions, and banquet sauce work. Saves hours of reduction time without sacrificing depth.', + pack_size: '6 x 1 L pouches', + units_per_case: 6, + unit_weight_lbs: 2.2, + case_weight_lbs: 13.2, + uom: 'case', + moq_cases: 1, + temperature_zone: 'frozen', + product_status: 'active', + allergens: 'Soy', + certifications: 'Gluten-Free', + is_sample_eligible: false, + categoryId: IDS.categories.pantry, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.products, + IDS.products.duckConfit, + { + sku: 'NS-DUC-2X5LB', + product_name: 'Heritage Duck Confit Legs', + brand: 'Northstar Reserve', + short_description: + 'Fully prepared duck confit legs for tasting menus, banquets, and upscale brunch programs.', + long_description: + 'Rendered and seasoned duck legs packed sous vide for clean thaw-and-fire execution in both fine dining and volume events.', + pack_size: '2 x 5 lb bags', + units_per_case: 2, + unit_weight_lbs: 5, + case_weight_lbs: 10, + uom: 'case', + moq_cases: 1, + temperature_zone: 'frozen', + product_status: 'active', + allergens: 'None', + certifications: 'No Antibiotics Ever', + is_sample_eligible: true, + categoryId: IDS.categories.proteins, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.products, + IDS.products.bloodOrangeSorbet, + { + sku: 'NS-SOR-8X2.5L', + product_name: 'Sicilian Blood Orange Sorbet', + brand: 'Northstar Frozen', + short_description: + 'Bright citrus sorbet with chef-friendly scooping texture and a clean finish.', + long_description: + 'Blood orange sorbet with high fruit content and banquet-stable texture. Works for plated desserts, palate cleansers, and bar service.', + pack_size: '8 x 2.5 L tubs', + units_per_case: 8, + unit_weight_lbs: 4.5, + case_weight_lbs: 36, + uom: 'case', + moq_cases: 1, + temperature_zone: 'frozen', + product_status: 'active', + allergens: 'None', + certifications: 'Vegan, Dairy-Free, Gluten-Free', + is_sample_eligible: true, + categoryId: IDS.categories.frozen, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.products, + IDS.products.fetaCrumble, + { + sku: 'NS-FET-4X2KG', + product_name: 'Sheep & Goat Feta Crumble', + brand: 'Northstar Reserve', + short_description: + 'Pre-crumbled feta blend for salads, flatbreads, grain bowls, and breakfast items.', + long_description: + 'Balanced sheep and goat milk feta crumble with a clean briny finish and low waste foodservice pack.', + pack_size: '4 x 2 kg bags', + units_per_case: 4, + unit_weight_lbs: 4.4, + case_weight_lbs: 17.6, + uom: 'case', + moq_cases: 1, + temperature_zone: 'refrigerated', + product_status: 'active', + allergens: 'Milk', + certifications: 'Vegetarian, Gluten-Free', + is_sample_eligible: true, + categoryId: IDS.categories.dairy, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.price_lists, + IDS.priceLists.harbor, + { + price_list_name: 'Northstar Contract - Harbor Table Hospitality', + price_list_type: 'contract', + effective_start: daysFromNow(-45), + effective_end: daysFromNow(120), + is_active: true, + notes: + 'Contract pricing for Harbor Table Hospitality Group spring/summer sourcing plan.', + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.price_lists, + IDS.priceLists.cedar, + { + price_list_name: 'Northstar Contract - Cedar & Sage Catering', + price_list_type: 'contract', + effective_start: daysFromNow(-30), + effective_end: daysFromNow(120), + is_active: true, + notes: + 'Catering-focused contract pricing with banquet dessert and pantry volume discounts.', + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.price_lists, + IDS.priceLists.seabrook, + { + price_list_name: 'Northstar Contract - Seabrook Hotel Collection', + price_list_type: 'contract', + effective_start: daysFromNow(-60), + effective_end: daysFromNow(150), + is_active: true, + notes: + 'Hotel banquet and in-room dining program pricing for Seabrook Hotel Collection.', + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.accounts, + IDS.accounts.harbor, + { + account_name: 'Harbor Table Hospitality Group', + account_type: 'restaurant', + account_number: 'HT-1043', + tax_exempt_number: null, + is_tax_exempt: false, + credit_status: 'good', + credit_limit: 35000, + notes: + 'Multi-location restaurant group focused on seasonal share plates and premium imported ingredients.', + is_active: true, + default_price_listId: IDS.priceLists.harbor, + assigned_sales_repId: IDS.users.salesRep, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.accounts, + IDS.accounts.cedar, + { + account_name: 'Cedar & Sage Catering Co.', + account_type: 'caterer', + account_number: 'CS-2088', + tax_exempt_number: 'IL-EX-88431', + is_tax_exempt: true, + credit_status: 'good', + credit_limit: 22000, + notes: + 'Special events caterer with tasting menus, corporate trays, and off-premise banquet production.', + is_active: true, + default_price_listId: IDS.priceLists.cedar, + assigned_sales_repId: IDS.users.salesRep, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.accounts, + IDS.accounts.seabrook, + { + account_name: 'Seabrook Hotel Collection', + account_type: 'hotel', + account_number: 'SH-3320', + tax_exempt_number: null, + is_tax_exempt: false, + credit_status: 'good', + credit_limit: 50000, + notes: + 'Hotel group operating banquets, lobby bar service, and in-room dining across two downtown properties.', + is_active: true, + default_price_listId: IDS.priceLists.seabrook, + assigned_sales_repId: IDS.users.salesRep, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.contacts, + IDS.contacts.maria, + { + contact_name: 'Maria Alvarez', + email: 'maria.alvarez@harbortable.com', + phone: '312-555-0141', + role_title: 'Purchasing Director', + is_primary: true, + can_place_orders: true, + can_see_invoices: true, + is_active: true, + accountId: IDS.accounts.harbor, + locationId: null, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.contacts, + IDS.contacts.owen, + { + contact_name: 'Owen Price', + email: 'owen.price@harbortable.com', + phone: '312-555-0170', + role_title: 'Executive Chef', + is_primary: false, + can_place_orders: true, + can_see_invoices: false, + is_active: true, + accountId: IDS.accounts.harbor, + locationId: null, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.contacts, + IDS.contacts.dana, + { + contact_name: 'Dana Kim', + email: 'dana@cedarsagecatering.com', + phone: '773-555-0123', + role_title: 'Event Operations Manager', + is_primary: true, + can_place_orders: true, + can_see_invoices: true, + is_active: true, + accountId: IDS.accounts.cedar, + locationId: null, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.contacts, + IDS.contacts.priya, + { + contact_name: 'Priya Raman', + email: 'priya.raman@seabrookhotels.com', + phone: '312-555-0162', + role_title: 'Director of Banquets', + is_primary: true, + can_place_orders: true, + can_see_invoices: true, + is_active: true, + accountId: IDS.accounts.seabrook, + locationId: null, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.contacts, + IDS.contacts.luca, + { + contact_name: 'Luca Moretti', + email: 'luca.moretti@seabrookhotels.com', + phone: '312-555-0165', + role_title: 'Executive Sous Chef', + is_primary: false, + can_place_orders: true, + can_see_invoices: false, + is_active: true, + accountId: IDS.accounts.seabrook, + locationId: null, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.locations, + IDS.locations.harborDowntown, + { + location_name: 'Harbor Table - Downtown', + location_type: 'shipping', + address_line_1: '145 West Kinzie Street', + address_line_2: 'Receiving Dock A', + city: 'Chicago', + state: 'IL', + postal_code: '60654', + country: 'USA', + delivery_instructions: + 'Deliver before 11:00 AM through dock A. Call purchasing on arrival.', + is_active: true, + accountId: IDS.accounts.harbor, + default_contactId: IDS.contacts.maria, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.locations, + IDS.locations.harborRiverNorth, + { + location_name: 'Harbor Table - River North', + location_type: 'shipping', + address_line_1: '302 North Wells Street', + address_line_2: null, + city: 'Chicago', + state: 'IL', + postal_code: '60654', + country: 'USA', + delivery_instructions: + 'Use alley entrance and confirm cold storage handoff with kitchen lead.', + is_active: true, + accountId: IDS.accounts.harbor, + default_contactId: IDS.contacts.owen, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.locations, + IDS.locations.cedarKitchen, + { + location_name: 'Cedar & Sage Production Kitchen', + location_type: 'shipping', + address_line_1: '4100 West Carroll Avenue', + address_line_2: 'Suite 3', + city: 'Chicago', + state: 'IL', + postal_code: '60624', + country: 'USA', + delivery_instructions: + 'Stage frozen pallets on rack 4 and dry goods at prep line entrance.', + is_active: true, + accountId: IDS.accounts.cedar, + default_contactId: IDS.contacts.dana, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.locations, + IDS.locations.seabrookMidtown, + { + location_name: 'Seabrook Hotel - Midtown Banquets', + location_type: 'shipping', + address_line_1: '800 North Michigan Avenue', + address_line_2: 'Banquet Receiving', + city: 'Chicago', + state: 'IL', + postal_code: '60611', + country: 'USA', + delivery_instructions: + 'Check in with banquet stewarding and label all banquet hold product.', + is_active: true, + accountId: IDS.accounts.seabrook, + default_contactId: IDS.contacts.priya, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.locations, + IDS.locations.seabrookWaterfront, + { + location_name: 'Seabrook Hotel - Waterfront', + location_type: 'shipping', + address_line_1: '1200 East Grand Avenue', + address_line_2: 'Dock 2', + city: 'Chicago', + state: 'IL', + postal_code: '60611', + country: 'USA', + delivery_instructions: + 'Morning deliveries only. Dock 2 access code provided to approved carriers.', + is_active: true, + accountId: IDS.accounts.seabrook, + default_contactId: IDS.contacts.luca, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.contacts, + IDS.contacts.maria, + { locationId: IDS.locations.harborDowntown, updatedById: adminUser.id }, + transaction, + ); + await upsertRecord( + db.contacts, + IDS.contacts.owen, + { + locationId: IDS.locations.harborRiverNorth, + updatedById: adminUser.id, + }, + transaction, + ); + await upsertRecord( + db.contacts, + IDS.contacts.dana, + { locationId: IDS.locations.cedarKitchen, updatedById: adminUser.id }, + transaction, + ); + await upsertRecord( + db.contacts, + IDS.contacts.priya, + { + locationId: IDS.locations.seabrookMidtown, + updatedById: adminUser.id, + }, + transaction, + ); + await upsertRecord( + db.contacts, + IDS.contacts.luca, + { + locationId: IDS.locations.seabrookWaterfront, + updatedById: adminUser.id, + }, + transaction, + ); + + const priceListItems = [ + [ + IDS.priceListItems.harborBurrata, + IDS.priceLists.harbor, + IDS.products.burrata, + 62, + 31, + 2, + ], + [ + IDS.priceListItems.harborRicotta, + IDS.priceLists.harbor, + IDS.products.ricottaSalata, + 48, + 8, + 1, + ], + [ + IDS.priceListItems.harborChili, + IDS.priceLists.harbor, + IDS.products.chiliCrunch, + 78, + 6.5, + 3, + ], + [ + IDS.priceListItems.harborDemi, + IDS.priceLists.harbor, + IDS.products.demiGlace, + 92, + 15.33, + 1, + ], + [ + IDS.priceListItems.harborDuck, + IDS.priceLists.harbor, + IDS.products.duckConfit, + 118, + 59, + 1, + ], + [ + IDS.priceListItems.harborSorbet, + IDS.priceLists.harbor, + IDS.products.bloodOrangeSorbet, + 72, + 9, + 1, + ], + [ + IDS.priceListItems.harborFeta, + IDS.priceLists.harbor, + IDS.products.fetaCrumble, + 58, + 14.5, + 1, + ], + [ + IDS.priceListItems.cedarBurrata, + IDS.priceLists.cedar, + IDS.products.burrata, + 66.5, + 33.25, + 2, + ], + [ + IDS.priceListItems.cedarRicotta, + IDS.priceLists.cedar, + IDS.products.ricottaSalata, + 50, + 8.33, + 1, + ], + [ + IDS.priceListItems.cedarChili, + IDS.priceLists.cedar, + IDS.products.chiliCrunch, + 81, + 6.75, + 3, + ], + [ + IDS.priceListItems.cedarDemi, + IDS.priceLists.cedar, + IDS.products.demiGlace, + 95, + 15.83, + 1, + ], + [ + IDS.priceListItems.cedarDuck, + IDS.priceLists.cedar, + IDS.products.duckConfit, + 121, + 60.5, + 1, + ], + [ + IDS.priceListItems.cedarSorbet, + IDS.priceLists.cedar, + IDS.products.bloodOrangeSorbet, + 69, + 8.63, + 1, + ], + [ + IDS.priceListItems.cedarFeta, + IDS.priceLists.cedar, + IDS.products.fetaCrumble, + 60, + 15, + 1, + ], + [ + IDS.priceListItems.seabrookBurrata, + IDS.priceLists.seabrook, + IDS.products.burrata, + 64, + 32, + 2, + ], + [ + IDS.priceListItems.seabrookRicotta, + IDS.priceLists.seabrook, + IDS.products.ricottaSalata, + 49, + 8.17, + 1, + ], + [ + IDS.priceListItems.seabrookChili, + IDS.priceLists.seabrook, + IDS.products.chiliCrunch, + 79, + 6.58, + 3, + ], + [ + IDS.priceListItems.seabrookDemi, + IDS.priceLists.seabrook, + IDS.products.demiGlace, + 94, + 15.67, + 1, + ], + [ + IDS.priceListItems.seabrookDuck, + IDS.priceLists.seabrook, + IDS.products.duckConfit, + 119, + 59.5, + 1, + ], + [ + IDS.priceListItems.seabrookSorbet, + IDS.priceLists.seabrook, + IDS.products.bloodOrangeSorbet, + 74, + 9.25, + 1, + ], + [ + IDS.priceListItems.seabrookFeta, + IDS.priceLists.seabrook, + IDS.products.fetaCrumble, + 59, + 14.75, + 1, + ], + ]; + + for (const [ + id, + priceListId, + productId, + casePrice, + unitPrice, + minCaseQty, + ] of priceListItems) { + await upsertRecord( + db.price_list_items, + id, + { + case_price: casePrice, + unit_price: unitPrice, + min_case_qty: minCaseQty, + effective_start: daysFromNow(-30), + effective_end: daysFromNow(120), + price_listId: priceListId, + productId, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + } + + await upsertRecord( + db.account_price_lists, + IDS.accountPriceLists.harbor, + { + effective_start: daysFromNow(-45), + effective_end: daysFromNow(120), + is_primary: true, + accountId: IDS.accounts.harbor, + price_listId: IDS.priceLists.harbor, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.account_price_lists, + IDS.accountPriceLists.cedar, + { + effective_start: daysFromNow(-30), + effective_end: daysFromNow(120), + is_primary: true, + accountId: IDS.accounts.cedar, + price_listId: IDS.priceLists.cedar, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.account_price_lists, + IDS.accountPriceLists.seabrook, + { + effective_start: daysFromNow(-60), + effective_end: daysFromNow(150), + is_primary: true, + accountId: IDS.accounts.seabrook, + price_listId: IDS.priceLists.seabrook, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.quotes, + IDS.quotes.harbor, + { + quote_number: 'QT-260501-HT', + requested_at: daysFromNow(-6), + expires_at: daysFromNow(12), + quote_status: 'sent', + quote_total: 202, + notes: + 'Menu testing for new burrata and Calabrian chili shareables rollout.', + accountId: IDS.accounts.harbor, + locationId: IDS.locations.harborDowntown, + requested_byId: IDS.users.buyerAdmin, + ownerId: IDS.users.salesRep, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.quotes, + IDS.quotes.cedar, + { + quote_number: 'QT-260428-CS', + requested_at: daysFromNow(-9), + expires_at: daysFromNow(5), + quote_status: 'in_review', + quote_total: 138, + notes: + 'Dessert tasting package for June gala dessert station proposals.', + accountId: IDS.accounts.cedar, + locationId: IDS.locations.cedarKitchen, + requested_byId: IDS.users.buyerAdmin, + ownerId: IDS.users.salesRep, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.quotes, + IDS.quotes.seabrook, + { + quote_number: 'QT-260423-SH', + requested_at: daysFromNow(-14), + expires_at: daysFromNow(3), + quote_status: 'accepted', + quote_total: 521, + notes: + 'Accepted banquet menu pricing for duck confit and demi-glace sauce station.', + accountId: IDS.accounts.seabrook, + locationId: IDS.locations.seabrookMidtown, + requested_byId: IDS.users.buyerAdmin, + ownerId: IDS.users.salesRep, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + const quoteItems = [ + [ + IDS.quoteItems.harborBurrata, + IDS.quotes.harbor, + IDS.products.burrata, + 2, + 62, + 124, + 'Opening week share plate feature', + ], + [ + IDS.quoteItems.harborChili, + IDS.quotes.harbor, + IDS.products.chiliCrunch, + 1, + 78, + 78, + 'Pizza and brunch finish', + ], + [ + IDS.quoteItems.cedarSorbet, + IDS.quotes.cedar, + IDS.products.bloodOrangeSorbet, + 2, + 69, + 138, + 'Dessert station tasting', + ], + [ + IDS.quoteItems.seabrookDuck, + IDS.quotes.seabrook, + IDS.products.duckConfit, + 3, + 119, + 357, + 'Banquet plated entrée', + ], + [ + IDS.quoteItems.seabrookDemi, + IDS.quotes.seabrook, + IDS.products.demiGlace, + 2, + 82, + 164, + 'Banquet sauce station', + ], + ]; + + for (const [ + id, + quoteId, + productId, + quantityCases, + casePrice, + lineTotal, + lineNotes, + ] of quoteItems) { + await upsertRecord( + db.quote_items, + id, + { + quantity_cases: quantityCases, + case_price: casePrice, + line_total: lineTotal, + line_notes: lineNotes, + quoteId, + productId, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + } + + await upsertRecord( + db.sample_requests, + IDS.sampleRequests.harbor, + { + sample_request_number: 'SR-260503-HT', + sample_quantity: 1, + requested_at: daysFromNow(-4), + needed_by: daysFromNow(2), + sample_status: 'approved', + notes: 'Need for weekend LTO tasting with spring peas and mint.', + accountId: IDS.accounts.harbor, + locationId: IDS.locations.harborDowntown, + requested_byId: IDS.users.buyerAdmin, + productId: IDS.products.burrata, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.sample_requests, + IDS.sampleRequests.cedar, + { + sample_request_number: 'SR-260505-CS', + sample_quantity: 1, + requested_at: daysFromNow(-2), + needed_by: daysFromNow(4), + sample_status: 'requested', + notes: 'Client tasting for rooftop summer dessert menu.', + accountId: IDS.accounts.cedar, + locationId: IDS.locations.cedarKitchen, + requested_byId: IDS.users.buyerAdmin, + productId: IDS.products.bloodOrangeSorbet, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.sample_requests, + IDS.sampleRequests.seabrook, + { + sample_request_number: 'SR-260430-SH', + sample_quantity: 1, + requested_at: daysFromNow(-7), + needed_by: daysFromNow(1), + sample_status: 'delivered', + notes: 'Lobby bar spread test for mezze service.', + accountId: IDS.accounts.seabrook, + locationId: IDS.locations.seabrookWaterfront, + requested_byId: IDS.users.buyerAdmin, + productId: IDS.products.chiliCrunch, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + const savedLists = [ + [ + IDS.savedLists.harborWeekend, + IDS.accounts.harbor, + IDS.users.buyerAdmin, + 'Weekend brunch restock', + 'reorder', + 'Core Harbor Table mix for burrata boards, mezze, and Calabrian chili brunch stations.', + ], + [ + IDS.savedLists.harborChefCounter, + IDS.accounts.harbor, + IDS.users.buyer, + 'Chef counter tasting menu', + 'reorder', + 'Duck confit and blood orange sorbet kept ready for tasting-menu resets and VIP nights.', + ], + [ + IDS.savedLists.seabrookBanquet, + IDS.accounts.seabrook, + IDS.users.buyerAdmin, + 'Banquet sauce and antipasti set', + 'reorder', + 'Banquet-ready par list for demi-glace production and lobby lounge antipasti replenishment.', + ], + ]; + + for (const [ + id, + accountId, + ownerId, + listName, + listType, + notes, + ] of savedLists) { + await upsertRecord( + db.saved_lists, + id, + { + list_name: listName, + list_type: listType, + notes, + accountId, + ownerId, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + } + + const savedListItems = [ + [ + IDS.savedListItems.harborWeekendBurrata, + IDS.savedLists.harborWeekend, + IDS.products.burrata, + 2, + 'Weekend burrata board prep', + ], + [ + IDS.savedListItems.harborWeekendChili, + IDS.savedLists.harborWeekend, + IDS.products.chiliCrunch, + 2, + 'Brunch pizza and egg station finish', + ], + [ + IDS.savedListItems.harborWeekendFeta, + IDS.savedLists.harborWeekend, + IDS.products.fetaCrumble, + 1, + 'Greek salad and mezze garnish', + ], + [ + IDS.savedListItems.harborChefCounterDuck, + IDS.savedLists.harborChefCounter, + IDS.products.duckConfit, + 1, + 'Chef counter duck confit course', + ], + [ + IDS.savedListItems.harborChefCounterSorbet, + IDS.savedLists.harborChefCounter, + IDS.products.bloodOrangeSorbet, + 2, + 'Sorbet reset for plated dessert finish', + ], + [ + IDS.savedListItems.seabrookBanquetDemi, + IDS.savedLists.seabrookBanquet, + IDS.products.demiGlace, + 2, + 'Banquet sauce station reserve', + ], + [ + IDS.savedListItems.seabrookBanquetBurrata, + IDS.savedLists.seabrookBanquet, + IDS.products.burrata, + 2, + 'Lobby lounge antipasti service', + ], + ]; + + for (const [ + id, + savedListId, + productId, + defaultQuantityCases, + notes, + ] of savedListItems) { + const savedListItem = await upsertRecord( + db.saved_list_items, + id, + { + default_quantity_cases: defaultQuantityCases, + notes, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await savedListItem.setSaved_list(savedListId, { transaction }); + await savedListItem.setProduct(productId, { transaction }); + } + + await upsertRecord( + db.orders, + IDS.orders.harborDelivered, + { + order_number: 'PO-260420-HT', + po_number: 'HT-PO-4821', + ordered_at: daysFromNow(-17), + requested_delivery_date: daysFromNow(-14), + promised_delivery_date: daysFromNow(-14), + order_status: 'delivered', + payment_terms: 'net_30', + subtotal: 356, + tax_total: 0, + shipping_total: 0, + order_total: 356, + buyer_notes: + 'Restock for spring menu launch and weekend private dining buyout.', + internal_notes: 'Delivered on time and received in full.', + accountId: IDS.accounts.harbor, + locationId: IDS.locations.harborDowntown, + buyerId: IDS.users.buyerAdmin, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.orders, + IDS.orders.harborSubmitted, + { + order_number: 'PO-260506-HT', + po_number: 'HT-PO-4898', + ordered_at: daysFromNow(-1), + requested_delivery_date: daysFromNow(3), + promised_delivery_date: null, + order_status: 'submitted', + payment_terms: 'net_30', + subtotal: 262, + tax_total: 0, + shipping_total: 0, + order_total: 262, + buyer_notes: + 'Weekend event prep for chef counter menu and dessert cart.', + internal_notes: null, + accountId: IDS.accounts.harbor, + locationId: IDS.locations.harborRiverNorth, + buyerId: IDS.users.buyer, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await upsertRecord( + db.orders, + IDS.orders.seabrookDelivered, + { + order_number: 'PO-260425-SH', + po_number: 'SH-PO-9034', + ordered_at: daysFromNow(-12), + requested_delivery_date: daysFromNow(-9), + promised_delivery_date: daysFromNow(-9), + order_status: 'delivered', + payment_terms: 'net_45', + subtotal: 420, + tax_total: 0, + shipping_total: 0, + order_total: 420, + buyer_notes: + 'Banquet and in-room dining replenishment for spring conference block.', + internal_notes: 'Priority delivery completed via dock 2.', + accountId: IDS.accounts.seabrook, + locationId: IDS.locations.seabrookMidtown, + buyerId: IDS.users.buyerAdmin, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + const orderItems = [ + [ + IDS.orderItems.harborDeliveredBurrata, + IDS.orders.harborDelivered, + IDS.products.burrata, + 2, + 62, + 124, + 'Burrata board program', + 'shipped', + ], + [ + IDS.orderItems.harborDeliveredChili, + IDS.orders.harborDelivered, + IDS.products.chiliCrunch, + 2, + 78, + 156, + 'Brunch and pizza stations', + 'shipped', + ], + [ + IDS.orderItems.harborDeliveredFeta, + IDS.orders.harborDelivered, + IDS.products.fetaCrumble, + 1, + 76, + 76, + 'Salad and mezze prep', + 'shipped', + ], + [ + IDS.orderItems.harborSubmittedDuck, + IDS.orders.harborSubmitted, + IDS.products.duckConfit, + 1, + 118, + 118, + 'Chef counter tasting menu', + 'pending', + ], + [ + IDS.orderItems.harborSubmittedSorbet, + IDS.orders.harborSubmitted, + IDS.products.bloodOrangeSorbet, + 2, + 72, + 144, + 'Dessert cart restock', + 'pending', + ], + [ + IDS.orderItems.seabrookDeliveredDemi, + IDS.orders.seabrookDelivered, + IDS.products.demiGlace, + 2, + 94, + 188, + 'Banquet sauce station', + 'shipped', + ], + [ + IDS.orderItems.seabrookDeliveredBurrata, + IDS.orders.seabrookDelivered, + IDS.products.burrata, + 2, + 64, + 128, + 'Lobby lounge antipasti', + 'shipped', + ], + ]; + + for (const [ + id, + orderId, + productId, + quantityCases, + casePrice, + lineTotal, + lineNotes, + fulfillmentStatus, + ] of orderItems) { + const orderItem = await upsertRecord( + db.order_items, + id, + { + quantity_cases: quantityCases, + case_price: casePrice, + line_total: lineTotal, + fulfillment_status: fulfillmentStatus, + line_notes: lineNotes, + createdById: adminUser.id, + updatedById: adminUser.id, + }, + transaction, + ); + + await orderItem.setOrder(orderId, { transaction }); + await orderItem.setProduct(productId, { transaction }); + } + }); + }, + + async down(queryInterface, Sequelize) { + const ids = [ + ...Object.values(IDS.orderItems), + ...Object.values(IDS.orders), + ...Object.values(IDS.savedListItems), + ...Object.values(IDS.savedLists), + ...Object.values(IDS.sampleRequests), + ...Object.values(IDS.quoteItems), + ...Object.values(IDS.quotes), + ...Object.values(IDS.accountPriceLists), + ...Object.values(IDS.priceListItems), + ...Object.values(IDS.locations), + ...Object.values(IDS.contacts), + ...Object.values(IDS.accounts), + ...Object.values(IDS.priceLists), + ...Object.values(IDS.products), + ...Object.values(IDS.categories), + ...Object.values(IDS.users), + ]; + + await queryInterface.bulkDelete('order_items', { + id: { [Sequelize.Op.in]: Object.values(IDS.orderItems) }, + }); + await queryInterface.bulkDelete('orders', { + id: { [Sequelize.Op.in]: Object.values(IDS.orders) }, + }); + await queryInterface.bulkDelete('saved_list_items', { + id: { [Sequelize.Op.in]: Object.values(IDS.savedListItems) }, + }); + await queryInterface.bulkDelete('saved_lists', { + id: { [Sequelize.Op.in]: Object.values(IDS.savedLists) }, + }); + await queryInterface.bulkDelete('sample_requests', { + id: { [Sequelize.Op.in]: Object.values(IDS.sampleRequests) }, + }); + await queryInterface.bulkDelete('quote_items', { + id: { [Sequelize.Op.in]: Object.values(IDS.quoteItems) }, + }); + await queryInterface.bulkDelete('quotes', { + id: { [Sequelize.Op.in]: Object.values(IDS.quotes) }, + }); + await queryInterface.bulkDelete('account_price_lists', { + id: { [Sequelize.Op.in]: Object.values(IDS.accountPriceLists) }, + }); + await queryInterface.bulkDelete('price_list_items', { + id: { [Sequelize.Op.in]: Object.values(IDS.priceListItems) }, + }); + await queryInterface.bulkDelete('locations', { + id: { [Sequelize.Op.in]: Object.values(IDS.locations) }, + }); + await queryInterface.bulkDelete('contacts', { + id: { [Sequelize.Op.in]: Object.values(IDS.contacts) }, + }); + await queryInterface.bulkDelete('accounts', { + id: { [Sequelize.Op.in]: Object.values(IDS.accounts) }, + }); + await queryInterface.bulkDelete('price_lists', { + id: { [Sequelize.Op.in]: Object.values(IDS.priceLists) }, + }); + await queryInterface.bulkDelete('products', { + id: { [Sequelize.Op.in]: Object.values(IDS.products) }, + }); + await queryInterface.bulkDelete('product_categories', { + id: { [Sequelize.Op.in]: Object.values(IDS.categories) }, + }); + await queryInterface.bulkDelete('users', { + id: { [Sequelize.Op.in]: Object.values(IDS.users) }, + }); + + await queryInterface.sequelize.query( + ` + DELETE FROM "rolesPermissionsPermissions" + WHERE ("roles_permissionsId", "permissionId") IN ( + SELECT r.id, p.id + FROM roles r + JOIN permissions p ON p.name IN ('CREATE_ORDERS', 'CREATE_ORDER_ITEMS') + WHERE r.name IN ('Customer Buyer Admin', 'Customer Buyer') + ) + `, + ); + + return ids; + }, +}; diff --git a/backend/src/index.js b/backend/src/index.js index 6e22977..e0a78e1 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -18,6 +18,7 @@ const sqlRoutes = require('./routes/sql'); const pexelsRoutes = require('./routes/pexels'); const openaiRoutes = require('./routes/openai'); +const buyerPortalRoutes = require('./routes/buyer_portal'); @@ -122,6 +123,7 @@ app.use('/api/file', fileRoutes); app.use('/api/pexels', pexelsRoutes); app.enable('trust proxy'); +app.use('/api/buyer_portal', passport.authenticate('jwt', {session: false}), buyerPortalRoutes); app.use('/api/users', passport.authenticate('jwt', {session: false}), usersRoutes); diff --git a/backend/src/routes/buyer_portal.js b/backend/src/routes/buyer_portal.js new file mode 100644 index 0000000..a910f6b --- /dev/null +++ b/backend/src/routes/buyer_portal.js @@ -0,0 +1,86 @@ +const express = require('express'); + +const BuyerPortalService = require('../services/buyer_portal'); +const wrapAsync = require('../helpers').wrapAsync; +const { checkPermissions } = require('../middlewares/check-permissions'); + +const router = express.Router(); + +const workspacePermissions = [ + 'READ_ACCOUNTS', + 'READ_LOCATIONS', + 'READ_CONTACTS', + 'READ_PRODUCTS', + 'READ_PRICE_LISTS', + 'READ_PRICE_LIST_ITEMS', + 'READ_ACCOUNT_PRICE_LISTS', + 'READ_ORDERS', + 'READ_ORDER_ITEMS', + 'READ_QUOTES', + 'READ_QUOTE_ITEMS', + 'READ_SAMPLE_REQUESTS', + 'READ_SAVED_LISTS', + 'READ_SAVED_LIST_ITEMS', +]; + +const createSavedListPermissions = [ + 'CREATE_SAVED_LISTS', + 'CREATE_SAVED_LIST_ITEMS', + 'READ_ACCOUNTS', + 'READ_PRODUCTS', + 'READ_PRICE_LIST_ITEMS', + 'READ_ACCOUNT_PRICE_LISTS', +]; + +const createOrderPermissions = [ + 'CREATE_ORDERS', + 'CREATE_ORDER_ITEMS', + 'READ_ACCOUNTS', + 'READ_LOCATIONS', + 'READ_PRODUCTS', + 'READ_PRICE_LIST_ITEMS', + 'READ_ACCOUNT_PRICE_LISTS', +]; + +router.get( + '/workspace', + ...workspacePermissions.map((permission) => checkPermissions(permission)), + wrapAsync(async (req, res) => { + const workspace = await BuyerPortalService.workspace({ + accountId: req.query.accountId, + locationId: req.query.locationId, + }); + + res.status(200).send(workspace); + }), +); + +router.post( + '/saved-lists', + ...createSavedListPermissions.map((permission) => + checkPermissions(permission), + ), + wrapAsync(async (req, res) => { + const savedList = await BuyerPortalService.createSavedList( + req.body, + req.currentUser, + ); + + res.status(200).send({ savedList }); + }), +); + +router.post( + '/orders', + ...createOrderPermissions.map((permission) => checkPermissions(permission)), + wrapAsync(async (req, res) => { + const order = await BuyerPortalService.createOrder( + req.body, + req.currentUser, + ); + + res.status(200).send({ order }); + }), +); + +module.exports = router; diff --git a/backend/src/services/buyer_portal.js b/backend/src/services/buyer_portal.js new file mode 100644 index 0000000..7ab4dff --- /dev/null +++ b/backend/src/services/buyer_portal.js @@ -0,0 +1,1074 @@ +const db = require('../db/models'); +const buyerPortalDemoSeeder = require('../db/seeders/20260507130000-buyer-portal-demo-data'); + +const { Op } = db.Sequelize; + +let buyerPortalSeedPromise = null; + +const ACTIVE_PRODUCT_STATUSES = ['active', 'seasonal', 'out_of_stock']; + +function toNumber(value) { + if (value === null || value === undefined || value === '') { + return null; + } + + const parsed = Number(value); + return Number.isNaN(parsed) ? null : parsed; +} + +function makeError(message) { + const error = new Error(message); + error.code = 400; + return error; +} + +function isCurrent(start, end) { + const now = new Date(); + + if (start && new Date(start) > now) { + return false; + } + + if (end && new Date(end) < now) { + return false; + } + + return true; +} + +function buildOrderNumber() { + const now = new Date(); + const stamp = [ + now.getUTCFullYear(), + String(now.getUTCMonth() + 1).padStart(2, '0'), + String(now.getUTCDate()).padStart(2, '0'), + ].join(''); + const suffix = String(Math.floor(1000 + Math.random() * 9000)); + + return `ORD-${stamp}-${suffix}`; +} + +function serializeUser(user) { + if (!user) { + return null; + } + + return { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + phoneNumber: user.phoneNumber, + }; +} + +function serializeContact(contact) { + if (!contact) { + return null; + } + + return { + id: contact.id, + accountId: contact.accountId, + locationId: contact.locationId, + contact_name: contact.contact_name, + email: contact.email, + phone: contact.phone, + role_title: contact.role_title, + is_primary: Boolean(contact.is_primary), + can_place_orders: Boolean(contact.can_place_orders), + can_see_invoices: Boolean(contact.can_see_invoices), + is_active: Boolean(contact.is_active), + }; +} + +function serializeLocation(location) { + if (!location) { + return null; + } + + return { + id: location.id, + accountId: location.accountId, + location_name: location.location_name, + location_type: location.location_type, + address_line_1: location.address_line_1, + address_line_2: location.address_line_2, + city: location.city, + state: location.state, + postal_code: location.postal_code, + country: location.country, + delivery_instructions: location.delivery_instructions, + is_active: Boolean(location.is_active), + default_contact: serializeContact(location.default_contact), + }; +} + +function serializeProductSummary(product) { + if (!product) { + return null; + } + + return { + id: product.id, + sku: product.sku, + product_name: product.product_name, + brand: product.brand, + pack_size: product.pack_size, + units_per_case: product.units_per_case, + moq_cases: product.moq_cases, + temperature_zone: product.temperature_zone, + product_status: product.product_status, + }; +} + +function serializeAccount(account, locations, contacts) { + return { + id: account.id, + account_name: account.account_name, + account_type: account.account_type, + account_number: account.account_number, + credit_status: account.credit_status, + credit_limit: toNumber(account.credit_limit), + notes: account.notes, + default_price_list: account.default_price_list + ? { + id: account.default_price_list.id, + price_list_name: account.default_price_list.price_list_name, + } + : null, + assigned_sales_rep: serializeUser(account.assigned_sales_rep), + locations: locations + .filter((location) => location.accountId === account.id) + .map(serializeLocation), + contacts: contacts + .filter((contact) => contact.accountId === account.id) + .map(serializeContact), + }; +} + +function serializeCatalogItem(product, pricing) { + return { + id: product.id, + sku: product.sku, + product_name: product.product_name, + brand: product.brand, + short_description: product.short_description, + long_description: product.long_description, + pack_size: product.pack_size, + units_per_case: product.units_per_case, + unit_weight_lbs: toNumber(product.unit_weight_lbs), + case_weight_lbs: toNumber(product.case_weight_lbs), + uom: product.uom, + moq_cases: product.moq_cases, + temperature_zone: product.temperature_zone, + product_status: product.product_status, + allergens: product.allergens, + certifications: product.certifications, + is_sample_eligible: Boolean(product.is_sample_eligible), + category: product.category + ? { + id: product.category.id, + category_name: product.category.category_name, + } + : null, + contract_case_price: toNumber(pricing.case_price), + contract_unit_price: toNumber(pricing.unit_price), + contract_min_case_qty: pricing.min_case_qty || product.moq_cases || 1, + contract_price_list: pricing.price_list_name, + price_list_id: pricing.priceListId, + }; +} + +function serializeOrder(order) { + const items = (order.order_items_order || []).map((item) => ({ + id: item.id, + quantity_cases: item.quantity_cases, + case_price: toNumber(item.case_price), + line_total: toNumber(item.line_total), + fulfillment_status: item.fulfillment_status, + line_notes: item.line_notes, + product: serializeProductSummary(item.product), + })); + + return { + id: order.id, + order_number: order.order_number, + po_number: order.po_number, + ordered_at: order.ordered_at, + requested_delivery_date: order.requested_delivery_date, + promised_delivery_date: order.promised_delivery_date, + order_status: order.order_status, + payment_terms: order.payment_terms, + subtotal: toNumber(order.subtotal), + tax_total: toNumber(order.tax_total), + shipping_total: toNumber(order.shipping_total), + order_total: toNumber(order.order_total), + buyer_notes: order.buyer_notes, + location: serializeLocation(order.location), + buyer: serializeUser(order.buyer), + items, + }; +} + +function serializeQuote(quote) { + const items = (quote.quote_items_quote || []).map((item) => ({ + id: item.id, + quantity_cases: item.quantity_cases, + case_price: toNumber(item.case_price), + line_total: toNumber(item.line_total), + line_notes: item.line_notes, + product: serializeProductSummary(item.product), + })); + + return { + id: quote.id, + quote_number: quote.quote_number, + requested_at: quote.requested_at, + expires_at: quote.expires_at, + quote_status: quote.quote_status, + quote_total: toNumber(quote.quote_total), + notes: quote.notes, + location: serializeLocation(quote.location), + requested_by: serializeUser(quote.requested_by), + owner: serializeUser(quote.owner), + items, + }; +} + +function serializeSampleRequest(sampleRequest) { + return { + id: sampleRequest.id, + sample_request_number: sampleRequest.sample_request_number, + sample_quantity: sampleRequest.sample_quantity, + requested_at: sampleRequest.requested_at, + needed_by: sampleRequest.needed_by, + sample_status: sampleRequest.sample_status, + notes: sampleRequest.notes, + product: serializeProductSummary(sampleRequest.product), + location: serializeLocation(sampleRequest.location), + requested_by: serializeUser(sampleRequest.requested_by), + }; +} + +function serializeSavedList(savedList) { + const items = (savedList.saved_list_items_saved_list || []).map((item) => ({ + id: item.id, + default_quantity_cases: item.default_quantity_cases, + notes: item.notes, + product: serializeProductSummary(item.product), + })); + + return { + id: savedList.id, + list_name: savedList.list_name, + list_type: savedList.list_type, + notes: savedList.notes, + createdAt: savedList.createdAt, + updatedAt: savedList.updatedAt, + owner: serializeUser(savedList.owner), + items, + }; +} + +async function ensureBuyerPortalSeeded() { + if (!buyerPortalSeedPromise) { + buyerPortalSeedPromise = Promise.resolve() + .then(() => buyerPortalDemoSeeder.up()) + .catch((error) => { + buyerPortalSeedPromise = null; + throw error; + }); + } + + await buyerPortalSeedPromise; +} + +async function getAccounts() { + const accounts = await db.accounts.findAll({ + where: { + is_active: true, + default_price_listId: { + [Op.ne]: null, + }, + }, + include: [ + { + model: db.price_lists, + as: 'default_price_list', + required: false, + }, + { + model: db.users, + as: 'assigned_sales_rep', + required: false, + }, + ], + order: [['createdAt', 'ASC']], + }); + + return accounts + .map((account) => account.get({ plain: true })) + .filter((account) => + account.default_price_list?.price_list_name?.startsWith( + 'Northstar Contract -', + ), + ) + .sort((left, right) => { + if (left.account_name === 'Harbor Table Hospitality Group') { + return -1; + } + + if (right.account_name === 'Harbor Table Hospitality Group') { + return 1; + } + + return (left.account_name || '').localeCompare(right.account_name || ''); + }); +} + +async function getLocations(accountIds) { + if (!accountIds.length) { + return []; + } + + const locations = await db.locations.findAll({ + where: { + accountId: { + [Op.in]: accountIds, + }, + is_active: true, + }, + include: [ + { + model: db.contacts, + as: 'default_contact', + required: false, + }, + ], + order: [['createdAt', 'ASC']], + }); + + return locations.map((location) => location.get({ plain: true })); +} + +async function getContacts(accountIds) { + if (!accountIds.length) { + return []; + } + + const contacts = await db.contacts.findAll({ + where: { + accountId: { + [Op.in]: accountIds, + }, + is_active: true, + }, + order: [ + ['is_primary', 'DESC'], + ['createdAt', 'ASC'], + ], + }); + + return contacts.map((contact) => contact.get({ plain: true })); +} + +async function getAccountPriceLists(account) { + const accountPriceLists = await db.account_price_lists.findAll({ + where: { + accountId: account.id, + }, + include: [ + { + model: db.price_lists, + as: 'price_list', + required: false, + }, + ], + order: [ + ['is_primary', 'DESC'], + ['createdAt', 'ASC'], + ], + }); + + const orderedPriceLists = []; + const seen = new Set(); + + accountPriceLists + .map((item) => item.get({ plain: true })) + .filter( + (item) => + item.price_listId && + isCurrent(item.effective_start, item.effective_end), + ) + .forEach((item) => { + if (seen.has(item.price_listId)) { + return; + } + + seen.add(item.price_listId); + orderedPriceLists.push({ + id: item.price_listId, + price_list_name: item.price_list?.price_list_name || 'Contract pricing', + }); + }); + + if (account.default_price_listId && !seen.has(account.default_price_listId)) { + orderedPriceLists.push({ + id: account.default_price_listId, + price_list_name: + account.default_price_list?.price_list_name || 'Default pricing', + }); + } + + return orderedPriceLists; +} + +async function getCatalogForAccount(account) { + if (!account) { + return []; + } + + const priceLists = await getAccountPriceLists(account); + const priceListIds = priceLists.map((item) => item.id); + + if (!priceListIds.length) { + return []; + } + + const priceListPriority = new Map( + priceListIds.map((priceListId, index) => [priceListId, index]), + ); + const priceListNames = new Map( + priceLists.map((priceList) => [priceList.id, priceList.price_list_name]), + ); + + const priceListItems = await db.price_list_items.findAll({ + where: { + price_listId: { + [Op.in]: priceListIds, + }, + }, + order: [['createdAt', 'DESC']], + }); + + const pricingByProduct = new Map(); + + priceListItems + .map((item) => item.get({ plain: true })) + .filter((item) => isCurrent(item.effective_start, item.effective_end)) + .sort((left, right) => { + const leftPriority = priceListPriority.get(left.price_listId) ?? 999; + const rightPriority = priceListPriority.get(right.price_listId) ?? 999; + return leftPriority - rightPriority; + }) + .forEach((item) => { + if (pricingByProduct.has(item.productId)) { + return; + } + + pricingByProduct.set(item.productId, { + priceListId: item.price_listId, + price_list_name: + priceListNames.get(item.price_listId) || 'Contract pricing', + case_price: item.case_price, + unit_price: item.unit_price, + min_case_qty: item.min_case_qty, + }); + }); + + const productIds = Array.from(pricingByProduct.keys()); + + if (!productIds.length) { + return []; + } + + const products = await db.products.findAll({ + where: { + id: { + [Op.in]: productIds, + }, + product_status: { + [Op.in]: ACTIVE_PRODUCT_STATUSES, + }, + }, + include: [ + { + model: db.product_categories, + as: 'category', + required: false, + }, + ], + order: [ + [ + { model: db.product_categories, as: 'category' }, + 'category_name', + 'ASC', + ], + ['product_name', 'ASC'], + ], + }); + + return products.map((product) => + serializeCatalogItem( + product.get({ plain: true }), + pricingByProduct.get(product.id), + ), + ); +} + +async function getRecentOrders(accountId) { + const orders = await db.orders.findAll({ + where: { + accountId, + }, + include: [ + { + model: db.locations, + as: 'location', + required: false, + include: [ + { + model: db.contacts, + as: 'default_contact', + required: false, + }, + ], + }, + { + model: db.users, + as: 'buyer', + required: false, + }, + { + model: db.order_items, + as: 'order_items_order', + required: false, + include: [ + { + model: db.products, + as: 'product', + required: false, + }, + ], + }, + ], + order: [ + ['ordered_at', 'DESC'], + ['createdAt', 'DESC'], + ], + limit: 6, + }); + + return orders.map((order) => serializeOrder(order.get({ plain: true }))); +} + +async function getRecentQuotes(accountId) { + const quotes = await db.quotes.findAll({ + where: { + accountId, + }, + include: [ + { + model: db.locations, + as: 'location', + required: false, + include: [ + { + model: db.contacts, + as: 'default_contact', + required: false, + }, + ], + }, + { + model: db.users, + as: 'requested_by', + required: false, + }, + { + model: db.users, + as: 'owner', + required: false, + }, + { + model: db.quote_items, + as: 'quote_items_quote', + required: false, + include: [ + { + model: db.products, + as: 'product', + required: false, + }, + ], + }, + ], + order: [ + ['requested_at', 'DESC'], + ['createdAt', 'DESC'], + ], + limit: 4, + }); + + return quotes.map((quote) => serializeQuote(quote.get({ plain: true }))); +} + +async function getRecentSamples(accountId) { + const sampleRequests = await db.sample_requests.findAll({ + where: { + accountId, + }, + include: [ + { + model: db.products, + as: 'product', + required: false, + }, + { + model: db.locations, + as: 'location', + required: false, + include: [ + { + model: db.contacts, + as: 'default_contact', + required: false, + }, + ], + }, + { + model: db.users, + as: 'requested_by', + required: false, + }, + ], + order: [ + ['requested_at', 'DESC'], + ['createdAt', 'DESC'], + ], + limit: 4, + }); + + return sampleRequests.map((sampleRequest) => + serializeSampleRequest(sampleRequest.get({ plain: true })), + ); +} + +async function getSavedLists(accountId) { + const savedLists = await db.saved_lists.findAll({ + where: { + accountId, + }, + include: [ + { + model: db.users, + as: 'owner', + required: false, + }, + { + model: db.saved_list_items, + as: 'saved_list_items_saved_list', + required: false, + include: [ + { + model: db.products, + as: 'product', + required: false, + }, + ], + }, + ], + order: [ + ['updatedAt', 'DESC'], + ['createdAt', 'DESC'], + ], + limit: 6, + }); + + return savedLists.map((savedList) => + serializeSavedList(savedList.get({ plain: true })), + ); +} + +module.exports = class BuyerPortalService { + static async workspace(filter = {}) { + await ensureBuyerPortalSeeded(); + + const accounts = await getAccounts(); + const accountIds = accounts.map((account) => account.id); + const locations = await getLocations(accountIds); + const contacts = await getContacts(accountIds); + const serializedAccounts = accounts.map((account) => + serializeAccount(account, locations, contacts), + ); + + const selectedAccount = + serializedAccounts.find((account) => account.id === filter.accountId) || + serializedAccounts[0] || + null; + + const selectedLocation = + selectedAccount?.locations.find( + (location) => location.id === filter.locationId, + ) || + selectedAccount?.locations[0] || + null; + + const selectedAccountWithPricing = accounts.find( + (account) => account.id === selectedAccount?.id, + ); + + const catalog = await getCatalogForAccount(selectedAccountWithPricing); + const recentOrders = selectedAccount + ? await getRecentOrders(selectedAccount.id) + : []; + const recentQuotes = selectedAccount + ? await getRecentQuotes(selectedAccount.id) + : []; + const recentSamples = selectedAccount + ? await getRecentSamples(selectedAccount.id) + : []; + const savedLists = selectedAccount + ? await getSavedLists(selectedAccount.id) + : []; + + return { + accounts: serializedAccounts, + selectedAccountId: selectedAccount?.id || '', + selectedLocationId: selectedLocation?.id || '', + catalog, + recentOrders, + recentQuotes, + recentSamples, + savedLists, + }; + } + + static async createSavedList(payload = {}, currentUser) { + await ensureBuyerPortalSeeded(); + + if (!currentUser?.id) { + throw makeError('You must be signed in to save a reorder list.'); + } + + if (!payload.accountId) { + throw makeError('Choose an account before saving a reorder list.'); + } + + if (!payload.listName || !String(payload.listName).trim()) { + throw makeError('Give this reorder list a name.'); + } + + if (!Array.isArray(payload.items) || !payload.items.length) { + throw makeError( + 'Add at least one product before saving the reorder list.', + ); + } + + const account = await db.accounts.findOne({ + where: { + id: payload.accountId, + is_active: true, + }, + include: [ + { + model: db.price_lists, + as: 'default_price_list', + required: false, + }, + ], + }); + + if (!account) { + throw makeError('The selected account could not be found.'); + } + + const catalog = await getCatalogForAccount(account.get({ plain: true })); + const catalogByProductId = new Map(catalog.map((item) => [item.id, item])); + + const listItems = payload.items.map((item) => { + const product = catalogByProductId.get(item.productId); + const quantityCases = Number.parseInt(String(item.quantityCases), 10); + + if (!product) { + throw makeError( + 'One of the selected products is no longer on the contract catalog.', + ); + } + + if (!Number.isInteger(quantityCases) || quantityCases <= 0) { + throw makeError( + 'Enter a valid case quantity for ' + product.product_name + '.', + ); + } + + const minQty = product.contract_min_case_qty || product.moq_cases || 1; + if (quantityCases < minQty) { + throw makeError( + product.product_name + + ' requires a minimum of ' + + minQty + + ' case(s).', + ); + } + + return { + product, + quantityCases, + }; + }); + + const transaction = await db.sequelize.transaction(); + + try { + const savedList = await db.saved_lists.create( + { + list_name: String(payload.listName).trim(), + list_type: 'reorder', + notes: payload.listNotes || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await savedList.setAccount(account.id, { transaction }); + await savedList.setOwner(currentUser.id, { transaction }); + + for (const item of listItems) { + const savedListItem = await db.saved_list_items.create( + { + default_quantity_cases: item.quantityCases, + notes: item.product.pack_size || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await savedListItem.setSaved_list(savedList.id, { transaction }); + await savedListItem.setProduct(item.product.id, { transaction }); + } + + await transaction.commit(); + + const createdSavedList = await db.saved_lists.findByPk(savedList.id, { + include: [ + { + model: db.users, + as: 'owner', + required: false, + }, + { + model: db.saved_list_items, + as: 'saved_list_items_saved_list', + required: false, + include: [ + { + model: db.products, + as: 'product', + required: false, + }, + ], + }, + ], + }); + + return serializeSavedList(createdSavedList.get({ plain: true })); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async createOrder(payload = {}, currentUser) { + await ensureBuyerPortalSeeded(); + + if (!currentUser?.id) { + throw makeError('You must be signed in to place a purchase order.'); + } + + if (!payload.accountId) { + throw makeError('Choose an account before placing an order.'); + } + + if (!payload.locationId) { + throw makeError('Choose a delivery location before placing an order.'); + } + + if (!payload.poNumber || !String(payload.poNumber).trim()) { + throw makeError('PO number is required for this order.'); + } + + if (!Array.isArray(payload.items) || !payload.items.length) { + throw makeError('Add at least one product to the draft order.'); + } + + const account = await db.accounts.findOne({ + where: { + id: payload.accountId, + is_active: true, + }, + include: [ + { + model: db.price_lists, + as: 'default_price_list', + required: false, + }, + ], + }); + + if (!account) { + throw makeError('The selected account could not be found.'); + } + + const location = await db.locations.findOne({ + where: { + id: payload.locationId, + accountId: payload.accountId, + is_active: true, + }, + }); + + if (!location) { + throw makeError( + 'The selected delivery location is invalid for this account.', + ); + } + + const catalog = await getCatalogForAccount(account.get({ plain: true })); + const catalogByProductId = new Map(catalog.map((item) => [item.id, item])); + + const orderItems = payload.items.map((item) => { + const product = catalogByProductId.get(item.productId); + const quantityCases = Number.parseInt(String(item.quantityCases), 10); + + if (!product) { + throw makeError( + 'One of the selected products is no longer on the contract catalog.', + ); + } + + if (!Number.isInteger(quantityCases) || quantityCases <= 0) { + throw makeError( + `Enter a valid case quantity for ${product.product_name}.`, + ); + } + + const minQty = product.contract_min_case_qty || product.moq_cases || 1; + if (quantityCases < minQty) { + throw makeError( + `${product.product_name} requires a minimum of ${minQty} case(s).`, + ); + } + + if (!product.contract_case_price) { + throw makeError( + `${product.product_name} does not have active contract pricing.`, + ); + } + + return { + product, + quantityCases, + casePrice: product.contract_case_price, + lineTotal: Number( + (product.contract_case_price * quantityCases).toFixed(2), + ), + }; + }); + + const subtotal = Number( + orderItems.reduce((sum, item) => sum + item.lineTotal, 0).toFixed(2), + ); + + const transaction = await db.sequelize.transaction(); + + try { + const order = await db.orders.create( + { + order_number: buildOrderNumber(), + po_number: String(payload.poNumber).trim(), + ordered_at: new Date(), + requested_delivery_date: payload.requestedDeliveryDate || null, + promised_delivery_date: null, + order_status: 'submitted', + payment_terms: payload.paymentTerms || 'net_30', + subtotal, + tax_total: 0, + shipping_total: 0, + order_total: subtotal, + buyer_notes: payload.buyerNotes || null, + internal_notes: null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await order.setAccount(account.id, { transaction }); + await order.setLocation(location.id, { transaction }); + await order.setBuyer(currentUser.id, { transaction }); + + for (const item of orderItems) { + const orderItem = await db.order_items.create( + { + quantity_cases: item.quantityCases, + case_price: item.casePrice, + line_total: item.lineTotal, + fulfillment_status: 'pending', + line_notes: item.product.pack_size, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await orderItem.setOrder(order.id, { transaction }); + await orderItem.setProduct(item.product.id, { transaction }); + } + + await transaction.commit(); + + const createdOrder = await db.orders.findByPk(order.id, { + include: [ + { + model: db.locations, + as: 'location', + required: false, + include: [ + { + model: db.contacts, + as: 'default_contact', + required: false, + }, + ], + }, + { + model: db.users, + as: 'buyer', + required: false, + }, + { + model: db.order_items, + as: 'order_items_order', + required: false, + include: [ + { + model: db.products, + as: 'product', + required: false, + }, + ], + }, + ], + }); + + return serializeOrder(createdOrder.get({ plain: true })); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index d60a88c..1b6ea50 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -7,6 +7,14 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, + { + href: '/buyer-portal', + label: 'Buyer Portal', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiCart' in icon ? icon['mdiCart' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_PRODUCTS' + }, { href: '/users/users-list', diff --git a/frontend/src/pages/buyer-portal.tsx b/frontend/src/pages/buyer-portal.tsx new file mode 100644 index 0000000..bbcb515 --- /dev/null +++ b/frontend/src/pages/buyer-portal.tsx @@ -0,0 +1,1489 @@ +import { + mdiAlertCircle, + mdiBasketOutline, + mdiChartTimelineVariant, + mdiFileDocumentOutline, + mdiFilterVariant, + mdiMagnify, + mdiMinus, + mdiPackageVariantClosed, + mdiPlus, + mdiTruckDelivery, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useMemo, useState } from 'react'; +import axios from 'axios'; + +import BaseButton from '../components/BaseButton'; +import BaseButtons from '../components/BaseButtons'; +import BaseIcon from '../components/BaseIcon'; +import CardBox from '../components/CardBox'; +import FormField from '../components/FormField'; +import NotificationBar from '../components/NotificationBar'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../config'; +import { hasPermission } from '../helpers/userPermissions'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import { useAppSelector } from '../stores/hooks'; + +type UserSummary = { + id: string; + firstName?: string; + lastName?: string; + email?: string; + phoneNumber?: string; +}; + +type Contact = { + id: string; + accountId: string; + locationId?: string | null; + contact_name: string; + email?: string | null; + phone?: string | null; + role_title?: string | null; + is_primary?: boolean; + can_place_orders?: boolean; + can_see_invoices?: boolean; +}; + +type Location = { + id: string; + accountId: string; + location_name: string; + location_type?: string | null; + address_line_1?: string | null; + address_line_2?: string | null; + city?: string | null; + state?: string | null; + postal_code?: string | null; + country?: string | null; + delivery_instructions?: string | null; + default_contact?: Contact | null; +}; + +type Account = { + id: string; + account_name: string; + account_type?: string | null; + account_number?: string | null; + credit_status?: string | null; + credit_limit?: number | null; + notes?: string | null; + default_price_list?: { + id: string; + price_list_name: string; + } | null; + assigned_sales_rep?: UserSummary | null; + locations: Location[]; + contacts: Contact[]; +}; + +type CatalogItem = { + id: string; + sku: string; + product_name: string; + brand?: string | null; + short_description?: string | null; + long_description?: string | null; + pack_size?: string | null; + units_per_case?: number | null; + uom?: string | null; + moq_cases?: number | null; + temperature_zone?: string | null; + product_status?: string | null; + allergens?: string | null; + certifications?: string | null; + is_sample_eligible?: boolean; + category?: { + id: string; + category_name: string; + } | null; + contract_case_price?: number | null; + contract_min_case_qty?: number | null; + contract_price_list?: string | null; +}; + +type LineProduct = { + id: string; + sku: string; + product_name: string; + brand?: string | null; + pack_size?: string | null; +}; + +type OrderLine = { + id: string; + quantity_cases: number; + case_price?: number | null; + line_total?: number | null; + fulfillment_status?: string | null; + line_notes?: string | null; + product?: LineProduct | null; +}; + +type OrderSummary = { + id: string; + order_number: string; + po_number?: string | null; + ordered_at?: string | null; + requested_delivery_date?: string | null; + promised_delivery_date?: string | null; + order_status?: string | null; + payment_terms?: string | null; + order_total?: number | null; + subtotal?: number | null; + buyer_notes?: string | null; + location?: Location | null; + buyer?: UserSummary | null; + items: OrderLine[]; +}; + +type QuoteSummary = { + id: string; + quote_number: string; + requested_at?: string | null; + expires_at?: string | null; + quote_status?: string | null; + quote_total?: number | null; + notes?: string | null; + owner?: UserSummary | null; + items: OrderLine[]; +}; + +type SampleRequestSummary = { + id: string; + sample_request_number: string; + sample_quantity?: number | null; + requested_at?: string | null; + needed_by?: string | null; + sample_status?: string | null; + notes?: string | null; + product?: LineProduct | null; + location?: Location | null; +}; + +type PortalData = { + accounts: Account[]; + selectedAccountId: string; + selectedLocationId: string; + catalog: CatalogItem[]; + recentOrders: OrderSummary[]; + recentQuotes: QuoteSummary[]; + recentSamples: SampleRequestSummary[]; +}; + +type CartItem = { + productId: string; + sku: string; + productName: string; + packSize: string; + quantityCases: number; + casePrice: number; + lineTotal: number; + moqCases: number; + temperatureZone?: string | null; +}; + +type BannerState = { + tone: 'success' | 'danger'; + message: string; +}; + +const currencyFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 2, +}); + +const dateFormatter = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', +}); + +const makeDateInput = (offsetDays = 0) => { + const date = new Date(); + date.setDate(date.getDate() + offsetDays); + return date.toISOString().slice(0, 10); +}; + +const splitTags = (value?: string | null) => + (value || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + +const formatCurrency = (value?: number | null) => { + if (value === null || value === undefined) { + return 'Quote required'; + } + + return currencyFormatter.format(value); +}; + +const formatDate = (value?: string | null) => { + if (!value) { + return '—'; + } + + return dateFormatter.format(new Date(value)); +}; + +const formatUserName = (user?: UserSummary | null) => { + if (!user) { + return 'Unassigned'; + } + + const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim(); + return name || user.email || 'Unassigned'; +}; + +const toTitle = (value?: string | null) => + (value || '') + .replace(/_/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()) || 'Unknown'; + +const statusClasses: Record = { + submitted: 'bg-blue-50 text-blue-700 border-blue-200', + delivered: 'bg-emerald-50 text-emerald-700 border-emerald-200', + approved: 'bg-emerald-50 text-emerald-700 border-emerald-200', + shipped: 'bg-sky-50 text-sky-700 border-sky-200', + picking: 'bg-violet-50 text-violet-700 border-violet-200', + requested: 'bg-amber-50 text-amber-700 border-amber-200', + sent: 'bg-blue-50 text-blue-700 border-blue-200', + accepted: 'bg-emerald-50 text-emerald-700 border-emerald-200', + in_review: 'bg-violet-50 text-violet-700 border-violet-200', + approved_sample: 'bg-emerald-50 text-emerald-700 border-emerald-200', + declined: 'bg-rose-50 text-rose-700 border-rose-200', + cancelled: 'bg-rose-50 text-rose-700 border-rose-200', + pending: 'bg-slate-100 text-slate-700 border-slate-200', + allocated: 'bg-sky-50 text-sky-700 border-sky-200', + backordered: 'bg-amber-50 text-amber-700 border-amber-200', +}; + +const StatusPill = ({ label }: { label?: string | null }) => { + const normalized = label || 'unknown'; + const colorClass = statusClasses[normalized] || 'bg-slate-100 text-slate-700 border-slate-200'; + + return ( + + {toTitle(normalized)} + + ); +}; + +const BuyerPortalPage = () => { + const { currentUser } = useAppSelector((state) => state.auth); + const [workspace, setWorkspace] = useState(null); + const [selectedAccountId, setSelectedAccountId] = useState(''); + const [selectedLocationId, setSelectedLocationId] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + const [catalogMode, setCatalogMode] = useState('order_guide'); + const [categoryFilter, setCategoryFilter] = useState('all'); + const [temperatureFilter, setTemperatureFilter] = useState('all'); + const [stockFilter, setStockFilter] = useState('orderable'); + const [catalogQuantities, setCatalogQuantities] = useState>({}); + const [cartItems, setCartItems] = useState([]); + const [poNumber, setPoNumber] = useState(''); + const [requestedDeliveryDate, setRequestedDeliveryDate] = useState(makeDateInput(4)); + const [paymentTerms, setPaymentTerms] = useState('net_30'); + const [buyerNotes, setBuyerNotes] = useState(''); + const [sampleProductId, setSampleProductId] = useState(''); + const [sampleQuantity, setSampleQuantity] = useState('1'); + const [sampleNeededBy, setSampleNeededBy] = useState(makeDateInput(7)); + const [sampleNotes, setSampleNotes] = useState(''); + const [banner, setBanner] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isSubmittingOrder, setIsSubmittingOrder] = useState(false); + const [isSubmittingSample, setIsSubmittingSample] = useState(false); + const [createdOrder, setCreatedOrder] = useState(null); + + const canCreateOrders = + !!currentUser && + hasPermission(currentUser, 'CREATE_ORDERS') && + hasPermission(currentUser, 'CREATE_ORDER_ITEMS'); + const canCreateSamples = !!currentUser && hasPermission(currentUser, 'CREATE_SAMPLE_REQUESTS'); + + const loadWorkspace = async (accountId?: string, locationId?: string) => { + try { + setIsLoading(true); + setErrorMessage(''); + + const { data } = await axios.get('/buyer_portal/workspace', { + params: { + accountId: accountId || undefined, + locationId: locationId || undefined, + }, + }); + + setWorkspace(data); + setSelectedAccountId(data.selectedAccountId || ''); + setSelectedLocationId(data.selectedLocationId || ''); + } catch (error: any) { + console.error('Failed to load buyer portal workspace', error); + setErrorMessage(error?.response?.data || 'Unable to load the buyer portal right now.'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadWorkspace(); + }, []); + + const currentAccount = useMemo( + () => workspace?.accounts.find((account) => account.id === selectedAccountId) || null, + [workspace, selectedAccountId], + ); + + const currentLocation = useMemo( + () => currentAccount?.locations.find((location) => location.id === selectedLocationId) || null, + [currentAccount, selectedLocationId], + ); + + const categoryOptions = useMemo(() => { + const categories = new Set(); + (workspace?.catalog || []).forEach((product) => { + if (product.category?.category_name) { + categories.add(product.category.category_name); + } + }); + + return Array.from(categories).sort(); + }, [workspace]); + + const temperatureOptions = useMemo(() => { + const zones = new Set(); + (workspace?.catalog || []).forEach((product) => { + if (product.temperature_zone) { + zones.add(product.temperature_zone); + } + }); + + return Array.from(zones).sort(); + }, [workspace]); + + const previouslyPurchasedProductIds = useMemo(() => { + const ids = new Set(); + (workspace?.recentOrders || []).forEach((order) => { + order.items.forEach((line) => { + if (line.product?.id) { + ids.add(line.product.id); + } + }); + }); + + return ids; + }, [workspace]); + + const filteredCatalog = useMemo(() => { + const source = workspace?.catalog || []; + const normalizedQuery = searchQuery.trim().toLowerCase(); + + return source.filter((product) => { + if (catalogMode === 'previously_purchased' && !previouslyPurchasedProductIds.has(product.id)) { + return false; + } + + if (categoryFilter !== 'all' && product.category?.category_name !== categoryFilter) { + return false; + } + + if (temperatureFilter !== 'all' && product.temperature_zone !== temperatureFilter) { + return false; + } + + if (stockFilter === 'orderable' && product.product_status === 'out_of_stock') { + return false; + } + + if (stockFilter === 'sample_eligible' && !product.is_sample_eligible) { + return false; + } + + if (!normalizedQuery) { + return true; + } + + const haystack = [ + product.product_name, + product.sku, + product.brand, + product.short_description, + product.category?.category_name, + product.allergens, + product.certifications, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + return haystack.includes(normalizedQuery); + }); + }, [ + catalogMode, + categoryFilter, + previouslyPurchasedProductIds, + searchQuery, + stockFilter, + temperatureFilter, + workspace, + ]); + + const selectedSampleProduct = useMemo( + () => workspace?.catalog.find((product) => product.id === sampleProductId) || null, + [sampleProductId, workspace], + ); + + const draftTotal = useMemo( + () => cartItems.reduce((sum, item) => sum + item.lineTotal, 0), + [cartItems], + ); + + const totalDraftCases = useMemo( + () => cartItems.reduce((sum, item) => sum + item.quantityCases, 0), + [cartItems], + ); + + const draftWarnings = useMemo(() => { + const warnings: string[] = []; + + if (!selectedLocationId) { + warnings.push('Choose a delivery location.'); + } + + if (!poNumber.trim()) { + warnings.push('PO number is required before checkout.'); + } + + if (!cartItems.length) { + warnings.push('Add at least one product to the draft.'); + } + + cartItems.forEach((item) => { + if (item.quantityCases < item.moqCases) { + warnings.push(`${item.productName} is below its ${item.moqCases}-case MOQ.`); + } + }); + + return warnings; + }, [cartItems, poNumber, selectedLocationId]); + + const suggestedItems = useMemo(() => { + const inDraft = new Set(cartItems.map((item) => item.productId)); + return (workspace?.catalog || []) + .filter((product) => product.contract_case_price && product.product_status !== 'out_of_stock') + .filter((product) => !inDraft.has(product.id)) + .slice(0, 3); + }, [cartItems, workspace]); + + const getLastOrderedLabel = (productId: string) => { + const matchingOrder = (workspace?.recentOrders || []).find((order) => + order.items.some((line) => line.product?.id === productId), + ); + + if (!matchingOrder) { + return 'Not ordered yet'; + } + + return `Last ordered ${formatDate(matchingOrder.ordered_at)}`; + }; + + const handleAccountChange = async (accountId: string) => { + setSelectedAccountId(accountId); + setSelectedLocationId(''); + setCartItems([]); + setCreatedOrder(null); + setSampleProductId(''); + setBanner({ + tone: 'success', + message: 'Buyer context updated. Draft order reset for the selected account.', + }); + await loadWorkspace(accountId, ''); + }; + + const handleLocationChange = async (locationId: string) => { + setSelectedLocationId(locationId); + await loadWorkspace(selectedAccountId, locationId); + }; + + const addProductToDraft = (product: CatalogItem) => { + const minQty = product.contract_min_case_qty || product.moq_cases || 1; + const requestedQty = Number.parseInt(catalogQuantities[product.id] || String(minQty), 10); + + if (!Number.isInteger(requestedQty) || requestedQty < minQty) { + setBanner({ + tone: 'danger', + message: `${product.product_name} requires at least ${minQty} case(s).`, + }); + return; + } + + if (product.product_status === 'out_of_stock') { + setBanner({ + tone: 'danger', + message: `${product.product_name} is currently out of stock and cannot be added to the order.`, + }); + return; + } + + if (!product.contract_case_price) { + setBanner({ + tone: 'danger', + message: `${product.product_name} does not have active contract pricing.`, + }); + return; + } + + setCartItems((currentItems) => { + const existingItem = currentItems.find((item) => item.productId === product.id); + if (existingItem) { + return currentItems.map((item) => + item.productId === product.id + ? { + ...item, + quantityCases: item.quantityCases + requestedQty, + lineTotal: (item.quantityCases + requestedQty) * item.casePrice, + } + : item, + ); + } + + return [ + ...currentItems, + { + productId: product.id, + sku: product.sku, + productName: product.product_name, + packSize: product.pack_size || 'Case pack', + quantityCases: requestedQty, + casePrice: product.contract_case_price, + lineTotal: product.contract_case_price * requestedQty, + moqCases: minQty, + temperatureZone: product.temperature_zone, + }, + ]; + }); + + setCreatedOrder(null); + setBanner({ + tone: 'success', + message: `${product.product_name} added to the draft PO at contract pricing.`, + }); + }; + + const updateDraftQuantity = (productId: string, nextValue: string) => { + const parsed = Number.parseInt(nextValue, 10); + + if (!Number.isInteger(parsed) || parsed <= 0) { + return; + } + + setCartItems((currentItems) => + currentItems.map((item) => + item.productId === productId + ? { + ...item, + quantityCases: parsed, + lineTotal: parsed * item.casePrice, + } + : item, + ), + ); + }; + + const removeDraftItem = (productId: string) => { + setCartItems((currentItems) => currentItems.filter((item) => item.productId !== productId)); + }; + + const loadOrderIntoDraft = (order: OrderSummary) => { + const catalogById = new Map((workspace?.catalog || []).map((item) => [item.id, item])); + const nextDraft: CartItem[] = []; + let skippedItems = 0; + + order.items.forEach((line) => { + if (!line.product?.id) { + skippedItems += 1; + return; + } + + const currentCatalogItem = catalogById.get(line.product.id); + if (!currentCatalogItem?.contract_case_price) { + skippedItems += 1; + return; + } + + nextDraft.push({ + productId: line.product.id, + sku: line.product.sku, + productName: line.product.product_name, + packSize: line.product.pack_size || 'Case pack', + quantityCases: line.quantity_cases, + casePrice: currentCatalogItem.contract_case_price, + lineTotal: currentCatalogItem.contract_case_price * line.quantity_cases, + moqCases: currentCatalogItem.contract_min_case_qty || currentCatalogItem.moq_cases || 1, + temperatureZone: currentCatalogItem.temperature_zone, + }); + }); + + setCartItems(nextDraft); + setPoNumber(''); + setBuyerNotes(`Reorder based on ${order.order_number}`); + setRequestedDeliveryDate(makeDateInput(4)); + setCreatedOrder(null); + setBanner({ + tone: skippedItems ? 'danger' : 'success', + message: skippedItems + ? `${order.order_number} loaded with ${skippedItems} unavailable item(s) skipped.` + : `${order.order_number} loaded into the draft PO with current contract pricing.`, + }); + + if (typeof window !== 'undefined') { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + }; + + const submitDraftOrder = async () => { + if (!selectedAccountId || !selectedLocationId) { + setBanner({ tone: 'danger', message: 'Choose both an account and a delivery location first.' }); + return; + } + + if (!cartItems.length) { + setBanner({ tone: 'danger', message: 'Add at least one product before submitting the PO.' }); + return; + } + + if (!poNumber.trim()) { + setBanner({ tone: 'danger', message: 'Enter a PO number before submitting the order.' }); + return; + } + + try { + setIsSubmittingOrder(true); + const { data } = await axios.post('/buyer_portal/orders', { + accountId: selectedAccountId, + locationId: selectedLocationId, + poNumber, + requestedDeliveryDate, + paymentTerms, + buyerNotes, + items: cartItems.map((item) => ({ + productId: item.productId, + quantityCases: item.quantityCases, + })), + }); + + setCreatedOrder(data.order); + setCartItems([]); + setPoNumber(''); + setBuyerNotes(''); + setRequestedDeliveryDate(makeDateInput(4)); + setBanner({ + tone: 'success', + message: `PO ${data.order.po_number} has been submitted and added to order history.`, + }); + + await loadWorkspace(selectedAccountId, selectedLocationId); + } catch (error: any) { + console.error('Failed to submit draft order', error); + setBanner({ + tone: 'danger', + message: error?.response?.data || 'Unable to place the purchase order right now.', + }); + } finally { + setIsSubmittingOrder(false); + } + }; + + const submitSampleRequest = async () => { + const quantity = Number.parseInt(sampleQuantity, 10); + + if (!selectedAccountId || !selectedLocationId || !selectedSampleProduct) { + setBanner({ + tone: 'danger', + message: 'Choose an account, location, and sample-eligible product before sending the request.', + }); + return; + } + + if (!Number.isInteger(quantity) || quantity <= 0) { + setBanner({ tone: 'danger', message: 'Enter a valid sample quantity.' }); + return; + } + + try { + setIsSubmittingSample(true); + await axios.post('/sample_requests', { + data: { + sample_request_number: `SR-${Date.now().toString().slice(-6)}`, + sample_quantity: quantity, + requested_at: new Date().toISOString(), + needed_by: sampleNeededBy, + sample_status: 'requested', + notes: sampleNotes, + account: selectedAccountId, + location: selectedLocationId, + requested_by: currentUser?.id, + product: selectedSampleProduct.id, + }, + }); + + setSampleQuantity('1'); + setSampleNeededBy(makeDateInput(7)); + setSampleNotes(''); + setSampleProductId(''); + setBanner({ + tone: 'success', + message: `${selectedSampleProduct.product_name} sample request has been sent to your distributor team.`, + }); + + await loadWorkspace(selectedAccountId, selectedLocationId); + } catch (error: any) { + console.error('Failed to create sample request', error); + setBanner({ + tone: 'danger', + message: error?.response?.data || 'Unable to create the sample request right now.', + }); + } finally { + setIsSubmittingSample(false); + } + }; + + const sampleEligibleCount = useMemo( + () => (workspace?.catalog || []).filter((item) => item.is_sample_eligible).length, + [workspace], + ); + + return ( + <> + + {getPageTitle('Buyer portal')} + + + + + + + + + + + +
+
+

Order guide workspace

+

+ Contract-priced replenishment for foodservice buyers +

+

+ Pick the account, ship-to, delivery date, and PO once. Then work the order guide like a procurement screen. +

+
+
+
+

Price file

+

+ {currentAccount?.default_price_list?.price_list_name || 'Loading pricing'} +

+
+
+

Sales rep

+

{formatUserName(currentAccount?.assigned_sales_rep)}

+
+
+

Cutoff

+

Today 3:00 PM

+
+
+
+ +
+ + + + + + + + setRequestedDeliveryDate(event.target.value)} + /> + + + setPoNumber(event.target.value)} placeholder="HT-PO-4901" /> + +
+ +
+
+

Contract SKUs

+

{workspace?.catalog.length || 0}

+
+
+

Sample-ready

+

{sampleEligibleCount}

+
+
+

Recent orders

+

{workspace?.recentOrders.length || 0}

+
+
+

Active quotes

+

+ {(workspace?.recentQuotes || []).filter((quote) => quote.quote_status !== 'expired').length} +

+
+
+
+ + {banner && ( + + {banner.message} + + )} + + {errorMessage && ( + + {errorMessage} + + )} + +
+ +
+
+

Contract catalog

+

Order guide

+

+ Work from familiar SKUs, contracted pricing, MOQs, pack sizes, and past purchases. +

+
+
+ {[ + ['order_guide', 'Order Guide'], + ['full_catalog', 'Full Catalog'], + ['previously_purchased', 'Previously Purchased'], + ].map(([mode, label]) => ( + + ))} +
+
+ +
+ +
+ + setSearchQuery(event.target.value)} + placeholder="Search SKU, ingredient, allergen, certification" + className="pl-10" + /> +
+
+ + + + + + + + + +
+ +
+
+
+
+

+ {filteredCatalog.length} SKU{filteredCatalog.length === 1 ? '' : 's'} ready to review +

+

+ Account pricing, last-order hints, MOQs, allergens, and pack details stay visible in each row. +

+
+ {isLoading &&

Refreshing…

} +
+ +
+ {filteredCatalog.map((product) => { + const minQty = product.contract_min_case_qty || product.moq_cases || 1; + const qtyValue = catalogQuantities[product.id] || String(minQty); + const currentQty = Number.parseInt(qtyValue, 10) || minQty; + return ( +
+
+
+
+ + {product.sku} + + + {product.category?.category_name || 'Specialty product'} + + + {product.is_sample_eligible && ( + + Sample-ready + + )} +
+

{product.product_name}

+

+ {[product.brand, product.pack_size, toTitle(product.temperature_zone)].filter(Boolean).join(' · ')} +

+

{product.short_description}

+
+ {splitTags(product.allergens).slice(0, 3).map((tag) => ( + + {tag} + + ))} + {splitTags(product.certifications).slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+
+
+
+

Contract case

+

+ {formatCurrency(product.contract_case_price)} +

+

MOQ {minQty} case(s)

+
+
+

Buying signal

+

{getLastOrderedLabel(product.id)}

+

{product.contract_price_list}

+
+
+
+ + + setCatalogQuantities((current) => ({ + ...current, + [product.id]: event.target.value, + })) + } + className="mx-2 w-full border-0 bg-transparent p-0 text-center text-lg font-semibold text-slate-950 focus:outline-none focus:ring-0" + /> + +
+ + addProductToDraft(product)} + disabled={!product.contract_case_price || product.product_status === 'out_of_stock'} + /> + {product.is_sample_eligible && canCreateSamples && ( + { + setSampleProductId(product.id); + setSampleQuantity('1'); + setSampleNotes(`Sample requested for ${product.product_name}.`); + }} + /> + )} + +
+
+
+
+ ); + })} +
+ + {!filteredCatalog.length && !isLoading && ( +
+ No products matched that search. Try a broader keyword or switch accounts. +
+ )} +
+ +
+ +
+
+

Delivery profile

+

{currentLocation?.location_name || 'Select a location'}

+
+
+ +
+
+
+
+

Ship-to address

+

+ {[currentLocation?.address_line_1, currentLocation?.address_line_2].filter(Boolean).join(', ')} +

+

+ {[currentLocation?.city, currentLocation?.state, currentLocation?.postal_code].filter(Boolean).join(', ')} +

+
+
+

Primary contact

+

{currentLocation?.default_contact?.contact_name || 'No default contact'}

+

{currentLocation?.default_contact?.role_title}

+

{currentLocation?.default_contact?.email}

+
+
+

Receiving notes

+

{currentLocation?.delivery_instructions || 'No location instructions on file.'}

+
+
+
+ + +
+
+

Account operations

+

Contacts and locations

+
+
+ +
+
+
+

+ {currentAccount?.contacts.length || 0} account contacts and{' '} + {currentAccount?.locations.length || 0} active ship-to locations are ready to support the buyer workflow. +

+

{currentAccount?.notes}

+
+ + + + + +
+
+
+
+ +
+ +
+
+

Draft purchase order

+

Review cart and checkout

+

{cartItems.length} line(s) · {totalDraftCases} case(s)

+
+
+ +
+
+ +
+
+
+

PO

+

{poNumber || 'Required'}

+
+
+

Delivery

+

{formatDate(requestedDeliveryDate)}

+
+
+

Ship-to

+

{currentLocation?.location_name || 'Select location'}

+
+
+

Payment

+ +
+
+ +