Wholesale B2b Quick Order
blockcommerceB2B wholesale bulk order matrix with volume tier pricing, case pack multipliers, and CSV SKU paste input.
Installation
$pnpm dlx uipkge-ng@latest add wholesale-b2b-quick-order$npx uipkge-ng@latest add wholesale-b2b-quick-order$yarn dlx uipkge-ng@latest add wholesale-b2b-quick-order$bunx uipkge-ng@latest add wholesale-b2b-quick-orderManifest:
https://uipkge.dev/r/angular/wholesale-b2b-quick-order.jsonInstalls to:src/app/components/blocks/wholesale-b2b-quick-order/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
WholesaleProductinterface WholesaleProduct {
id: string
sku: string
name: string
variant: string
casePack: number
msrpUnitPrice: number
wholesaleUnitPrice: number
cases: number
availableCases: number
image: string
category: string
}WholesaleOrderPayloadinterface WholesaleOrderPayload {
poNumber: string
items: WholesaleProduct[]
subtotal: number
totalUnits: number
totalCases: number
savings: number
}WholesalePasteFeedbackinterface WholesalePasteFeedback {
type: 'success' | 'error'
message: string
}Files installed (2)
src/app/components/blocks/wholesale-b2b-quick-order/wholesale-b2b-quick-order.component.ts36.9 kB
import { Component, DestroyRef, EventEmitter, Input, type OnInit, Output, computed, inject, signal, ChangeDetectionStrategy, } from '@angular/core' import { AlertCircle, Bookmark, Building2, CheckCircle2, Download, FileSpreadsheet, Layers, LucideAngularModule, Minus, Package, Plus, RefreshCw, Send, Trash2, TrendingDown, Truck, Upload, X, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardHeaderComponent, UiCardTitleComponent, } from '@/ui/card/card.component' import { UiInputComponent } from '@/ui/input/input.component' import { UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, } from '@/ui/table/table.component' import { UiTextareaComponent } from '@/ui/textarea/textarea.component' export interface WholesaleProduct { id: string sku: string name: string variant: string casePack: number msrpUnitPrice: number wholesaleUnitPrice: number cases: number availableCases: number image: string category: string } export interface WholesaleOrderPayload { poNumber: string items: WholesaleProduct[] subtotal: number totalUnits: number totalCases: number savings: number } export interface WholesalePasteFeedback { type: 'success' | 'error' message: string } export const DEFAULT_WHOLESALE_PRODUCTS: WholesaleProduct[] = [ { id: 'prod-1', sku: 'SKU-84920', name: 'Pro Studio Headphones', variant: 'Matte Black · Pro Series', casePack: 12, msrpUnitPrice: 299.0, wholesaleUnitPrice: 194.35, cases: 4, availableCases: 450, image: 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=300&auto=format&fit=crop&q=80', category: 'Audio & Acoustics', }, { id: 'prod-2', sku: 'SKU-49102', name: 'Aero Minimalist Runner', variant: 'Arctic White · US 10-12 Assorted', casePack: 10, msrpUnitPrice: 140.0, wholesaleUnitPrice: 91.0, cases: 3, availableCases: 180, image: 'https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=300&auto=format&fit=crop&q=80', category: 'Footwear', }, { id: 'prod-3', sku: 'SKU-77215', name: 'Technical Shell Parka', variant: 'Mineral Gray · Waterproof 3L', casePack: 8, msrpUnitPrice: 220.0, wholesaleUnitPrice: 143.0, cases: 2, availableCases: 95, image: 'https://images.unsplash.com/photo-1591047139829-d91aecb6caea?w=300&auto=format&fit=crop&q=80', category: 'Outerwear', }, { id: 'prod-4', sku: 'SKU-10934', name: 'Braided USB-C Cable', variant: 'Space Gray · 2m 240W EPR', casePack: 24, msrpUnitPrice: 25.0, wholesaleUnitPrice: 16.25, cases: 2, availableCases: 620, image: 'https://images.unsplash.com/photo-1583863788434-e58a36330cf0?w=300&auto=format&fit=crop&q=80', category: 'Hardware & Cables', }, { id: 'prod-5', sku: 'SKU-63821', name: 'Leather Cardholder', variant: 'Saddle Brown · Full-Grain Veg-Tan', casePack: 20, msrpUnitPrice: 50.0, wholesaleUnitPrice: 32.5, cases: 1, availableCases: 24, image: 'https://images.unsplash.com/photo-1627123424574-724758594e93?w=300&auto=format&fit=crop&q=80', category: 'Leather Goods', }, ] const currencyFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2, }) const numberFormatter = new Intl.NumberFormat('en-US') const clone = (items: WholesaleProduct[]): WholesaleProduct[] => items.map((p) => ({ ...p })) const SAMPLE_CSV = `SKU-84920, 8\nSKU-49102, 5\nSKU-77215, 4\nSKU-10934, 12\nSKU-63821, 6` /** * Angular port of the React WholesaleB2bQuickOrder block: a B2B bulk order matrix with * tier pricing, case pack multipliers, a CSV SKU paste box and a sticky order summary. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-wholesale-b2b-quick-order, [ui-wholesale-b2b-quick-order]', standalone: true, host: { '[attr.data-slot]': '"wholesale-b2b-quick-order"', '[class]': 'hostClass', }, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardHeaderComponent, UiCardTitleComponent, UiInputComponent, UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, UiTextareaComponent, ], template: ` <!-- Header & Account Info --> <div ui-card class="border-border bg-card shadow-xs"> <div ui-card-header class="pb-4"> <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div class="space-y-1.5"> <div class="flex flex-wrap items-center gap-2"> <div class="text-muted-foreground flex items-center gap-1.5 text-xs font-medium"> <lucide-icon [img]="Building2" class="size-3.5" /> <span>{{ accountName }}</span> <span>·</span> <span>{{ accountTier }}</span> </div> <span ui-badge variant="outline" class="border-success/30 bg-success/10 text-success font-medium"> <!-- [class]: a static class on lucide-icon lands on the host and the svg, doubling mr-1. --> <lucide-icon [img]="Layers" [class]="'mr-1 size-3'" />{{ tierDiscountPercent }}% Off MSRP Wholesale Tier </span> </div> <h3 ui-card-title class="text-xl font-bold tracking-tight sm:text-2xl"> Wholesale & B2B Bulk Order Matrix </h3> <p ui-card-description class="text-muted-foreground text-xs sm:text-sm"> Build your purchase order by entering case quantities below or pasting bulk SKU manifests with automated case multipliers. </p> </div> <!-- Header Action Buttons --> <div class="flex flex-wrap items-center gap-2"> <button ui-button aria-label="Close CSV paste" variant="outline" size="sm" class="gap-1.5 text-xs font-medium" (click)="showCsvBox.set(!showCsvBox())" > <lucide-icon [img]="FileSpreadsheet" class="size-3.5" /> <span>{{ showCsvBox() ? 'Hide CSV Paste' : 'Upload CSV Order' }}</span> </button> <button ui-button variant="outline" size="sm" class="gap-1.5 text-xs font-medium" (click)="handleSaveTemplate()" > <lucide-icon [img]="Bookmark" class="size-3.5" /> <span>Save Order Template</span> </button> </div> </div> </div> <div ui-card-content class="pt-0"> <!-- Account Terms & Partner Meta Strip --> <div class="bg-muted/40 border-border grid grid-cols-2 gap-3 rounded-lg border p-3 sm:grid-cols-4 sm:gap-4"> <div class="space-y-0.5"> <div class="text-muted-foreground text-xs font-medium">PO Reference</div> <div class="font-mono text-sm font-semibold">{{ poRef() }}</div> </div> <div class="space-y-0.5"> <div class="text-muted-foreground text-xs font-medium">Payment Terms</div> <div class="text-foreground flex items-center gap-1 text-sm font-semibold"> <lucide-icon [img]="CheckCircle2" class="text-success size-3.5" /> <span>{{ paymentTerms }}</span> </div> </div> <div class="space-y-0.5"> <div class="text-muted-foreground text-xs font-medium">Freight Status</div> <div [class]="cn('text-sm font-semibold tabular-nums', isFreeFreight ? 'text-success' : 'text-foreground')"> {{ isFreeFreight ? 'Free Ground Freight' : '$250 Standard Freight' }} </div> </div> <div class="space-y-0.5"> <div class="text-muted-foreground text-xs font-medium">Volume Tier Status</div> <div class="text-foreground text-sm font-semibold"> Tier 3 <span class="text-muted-foreground font-normal">({{ tierDiscountPercent }}% Margin)</span> </div> </div> </div> <!-- Volume Tier Margin Incentive Banner --> <div class="border-border bg-card/60 mt-3 rounded-lg border p-3"> <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div class="flex items-center gap-2"> <lucide-icon [img]="TrendingDown" class="text-success size-4 shrink-0" /> <div class="text-xs"> <span class="text-foreground font-semibold">Tier 4 Milestone ($50,000):</span> <span class="text-muted-foreground ml-1">{{ nextTierRemaining() > 0 ? 'Add ' + formatCurrency(nextTierRemaining()) + ' more to unlock 42% Tier 4 Enterprise Margin.' : 'Tier 4 Enterprise Margin (42%) Unlocked!' }}</span> </div> </div> <div class="text-muted-foreground flex items-center gap-2 text-xs"> <span>{{ tierProgressPercent() }}% to Tier 4</span> <div class="bg-muted border-border h-2 w-24 overflow-hidden rounded-full border"> <div class="bg-success h-full transition-[width] duration-300" [style.width.%]="tierProgressPercent()" ></div> </div> </div> </div> </div> </div> </div> <!-- Template Saved Alert --> @if (templateSaved()) { <div class="border-success/30 bg-success/10 text-success text-success flex items-center justify-between rounded-lg border p-3 text-xs sm:text-sm" > <div class="flex items-center gap-2"> <lucide-icon [img]="Bookmark" class="text-success size-4 shrink-0" /> <span>Order matrix saved as your primary template. You can re-populate this draft at any time.</span> </div> <button ui-button variant="ghost" size="xs" class="text-success hover:text-success" aria-label="Dismiss notification" (click)="templateSaved.set(false)" > <lucide-icon [img]="X" class="size-3.5" /> </button> </div> } <!-- Order Submitted Success Alert --> @if (orderSubmitted()) { <div class="border-success/30 bg-success/10 text-success dark:text-foreground rounded-xl border p-4 sm:p-5"> <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="flex items-start gap-3"> <lucide-icon [img]="CheckCircle2" [class]="'text-success mt-0.5 size-5 shrink-0'" /> <div class="space-y-1"> <h3 class="text-sm font-semibold sm:text-base">Purchase Order {{ poRef() }} Submitted Successfully!</h3> <p class="text-muted-foreground text-xs sm:text-sm"> Your bulk order of <strong class="text-foreground">{{ totalCases() }} cases ({{ totalUnits() }} units)</strong> totaling <strong class="text-foreground">{{ formatCurrency(grandTotal) }}</strong> has been queued for warehouse dispatch under {{ paymentTerms }}. </p> </div> </div> <div class="flex items-center gap-2"> <button ui-button aria-label="Download attachment" variant="outline" size="sm" class="text-xs" (click)="handleExportCsv()" > <lucide-icon [img]="Download" [class]="'mr-1.5 size-3.5'" /> Download PO PDF </button> <button ui-button size="sm" class="text-xs" (click)="orderSubmitted.set(false)">Start New Order</button> </div> </div> </div> } <!-- Quick SKU & Quantity CSV Paste Box --> @if (showCsvBox()) { <div ui-card class="border-border bg-card shadow-xs transition-colors duration-200"> <div ui-card-header class="pb-3"> <div class="flex items-center justify-between"> <div class="flex items-center gap-2"> <lucide-icon [img]="FileSpreadsheet" class="text-primary size-4" /> <h3 ui-card-title class="text-sm font-semibold sm:text-base">Quick Paste SKU & Quantity Manifest</h3> </div> <button ui-button aria-label="Close CSV paste" variant="ghost" size="xs" class="text-muted-foreground hover:text-foreground" (click)="showCsvBox.set(false)" > <lucide-icon [img]="X" class="size-4" /> </button> </div> <p ui-card-description class="text-muted-foreground text-xs"> Paste CSV rows with SKU identifier and case quantity (one item per line, e.g. <code class="bg-muted rounded px-1 font-mono">SKU-84920, 10</code>) to auto-populate matrix. </p> </div> <div ui-card-content class="space-y-3"> <ui-textarea [value]="csvPasteText()" (valueChange)="csvPasteText.set($event)" [placeholder]="csvPlaceholder" [rows]="4" class="font-mono text-xs" /> <div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center gap-2"> <button ui-button size="sm" class="text-xs font-medium" (click)="applyCsvPaste()"> <lucide-icon [img]="Upload" [class]="'mr-1.5 size-3.5'" /> Apply SKU List to Matrix </button> <button ui-button variant="outline" size="sm" class="text-xs font-medium" (click)="loadSampleCsv()"> Load Sample Data </button> @if (csvPasteText()) { <button ui-button variant="ghost" size="sm" class="text-muted-foreground text-xs" (click)="csvPasteText.set('')" > Clear Input </button> } </div> <div class="text-muted-foreground text-xs">Supports comma, tab, and semicolon delimited lines</div> </div> <!-- Parse Feedback Alert --> @if (pasteFeedback(); as feedback) { <div [class]=" cn( 'flex items-center gap-2 rounded-lg border p-2.5 text-xs', feedback.type === 'success' ? 'border-success/30 bg-success/10 text-success' : 'border-destructive/30 bg-destructive/10 text-destructive' ) " > @if (feedback.type === 'success') { <lucide-icon [img]="CheckCircle2" class="text-success size-4 shrink-0" /> } @else { <lucide-icon [img]="AlertCircle" class="size-4 shrink-0" /> } <span>{{ feedback.message }}</span> </div> } </div> </div> } <!-- Bulk SKU Matrix Table Card --> <div ui-card class="border-border bg-card shadow-xs"> <div ui-card-header class="border-border border-b pb-3"> <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div> <h3 ui-card-title class="text-base font-semibold">Bulk SKU Order Matrix</h3> <p ui-card-description class="text-muted-foreground text-xs"> {{ products().length }} wholesale catalog items available with active case multipliers. </p> </div> <div class="flex items-center gap-2"> <button ui-button variant="outline" size="xs" class="text-muted-foreground text-xs" (click)="restoreDefaultCases()" > <lucide-icon [img]="RefreshCw" [class]="'mr-1 size-3'" /> Reset Defaults </button> <button ui-button variant="outline" size="xs" class="text-muted-foreground text-xs" (click)="resetAllCases()" > <lucide-icon [img]="Trash2" [class]="'mr-1 size-3'" /> Zero All </button> </div> </div> </div> <div ui-card-content class="p-0"> <div class="overflow-x-auto"> <div ui-table class="w-full max-w-[760px] min-w-full"> <thead ui-table-header> <tr ui-table-row class="bg-muted/30"> <th ui-table-head class="w-[300px] text-xs font-semibold">Product & SKU Identifier</th> <th ui-table-head class="text-xs font-semibold">Case Multiplier</th> <th ui-table-head class="text-xs font-semibold">Unit Price</th> <th ui-table-head class="text-xs font-semibold">Case Quantity</th> <th ui-table-head class="text-xs font-semibold">Availability</th> <th ui-table-head class="text-right text-xs font-semibold">Line Total</th> <th ui-table-head class="w-[50px]"> <span class="sr-only">Actions</span> </th> </tr> </thead> <tbody ui-table-body> @for (product of products(); track product.id) { <tr ui-table-row [class]="cn('transition-colors', product.cases > 0 && 'bg-primary/5 dark:bg-primary/5')" > <!-- Product Thumbnail & Details --> <td ui-table-cell class="py-3.5"> <div class="flex items-center gap-3"> <img [src]="product.image" [alt]="product.name" class="border-border bg-muted/40 size-12 shrink-0 rounded-lg border object-cover" loading="lazy" /> <div class="min-w-0 space-y-0.5"> <div class="text-foreground truncate text-sm font-medium">{{ product.name }}</div> <div class="text-muted-foreground flex items-center gap-1.5 text-xs"> <span class="font-mono">{{ product.sku }}</span> <span>·</span> <span class="truncate">{{ product.variant }}</span> </div> </div> </div> </td> <!-- Case Pack Multiplier --> <td ui-table-cell class="py-3.5"> <div class="flex items-center gap-1.5"> <lucide-icon [img]="Package" class="text-muted-foreground size-3.5" /> <span class="text-foreground font-mono text-xs font-medium tabular-nums" >{{ product.casePack }} units / case</span > </div> </td> <!-- Pricing (MSRP vs Wholesale) --> <td ui-table-cell class="py-3.5"> <div class="space-y-0.5"> <div class="text-foreground text-sm font-semibold tabular-nums"> {{ formatCurrency(product.wholesaleUnitPrice) }}<span class="text-muted-foreground text-xs font-normal"> wholesale</span> </div> <div class="text-muted-foreground flex items-center gap-1.5 text-xs"> <span class="tabular-nums line-through">{{ formatCurrency(product.msrpUnitPrice) }} MSRP</span> <span class="text-success font-medium">-{{ discountPercent(product) }}%</span> </div> </div> </td> <!-- Quantity Stepper Input --> <td ui-table-cell class="py-3.5"> <div class="space-y-1"> <div class="flex items-center gap-1"> <button ui-button aria-label="Decrease cases" variant="outline" size="xs" class="size-7 p-0" [disabled]="product.cases <= 0" (click)="updateCases(product.id, -1)" > <lucide-icon [img]="Minus" class="size-3" /> </button> <ui-input [value]="product.cases" type="number" [min]="0" [max]="product.availableCases" class="h-7 w-16 text-center font-mono text-xs tabular-nums" (valueChange)="setCases(product.id, $event)" /> <button ui-button aria-label="Increase cases" variant="outline" size="xs" class="size-7 p-0" [disabled]="product.cases >= product.availableCases" (click)="updateCases(product.id, 1)" > <lucide-icon [img]="Plus" class="size-3" /> </button> </div> <div class="text-muted-foreground font-mono text-xs tabular-nums"> {{ product.cases }} {{ product.cases === 1 ? 'case' : 'cases' }} = <strong class="text-foreground">{{ product.cases * product.casePack }}</strong> units </div> </div> </td> <!-- Stock Status --> <td ui-table-cell class="py-3.5"> @if (product.availableCases > 50) { <span ui-badge variant="outline" class="border-success/30 bg-success/10 text-success text-xs font-medium" > In Stock · {{ formatNumber(product.availableCases) }} cases </span> } @else { <span ui-badge variant="outline" class="border-warning/30 bg-warning/10 text-warning text-xs font-medium" > Low Stock · {{ product.availableCases }} cases </span> } </td> <!-- Line Total --> <td ui-table-cell class="py-3.5 text-right"> <div class="space-y-0.5"> <div class="text-foreground font-mono text-sm font-bold tabular-nums"> {{ formatCurrency(product.cases * product.casePack * product.wholesaleUnitPrice) }} </div> @if (product.cases > 0) { <div class="text-success text-success font-mono text-xs tabular-nums"> Save {{ formatCurrency( product.cases * product.casePack * (product.msrpUnitPrice - product.wholesaleUnitPrice) ) }} </div> } @else { <div class="text-muted-foreground text-xs">0 units</div> } </div> </td> <!-- Row Clear Action --> <td ui-table-cell class="py-3.5 text-center"> @if (product.cases > 0) { <button ui-button variant="ghost" size="xs" class="text-muted-foreground hover:text-destructive size-7 p-0" title="Clear row quantity" (click)="clearLine(product.id)" > <lucide-icon [img]="Trash2" class="size-3.5" /> </button> } </td> </tr> } </tbody> </div> </div> </div> </div> <!-- Sticky Order Summary Footer --> <div class="bg-card/95 border-border sticky bottom-4 z-20 rounded-xl border p-4 shadow-lg backdrop-blur-md transition-colors sm:p-5" > <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <!-- Summary Totals Metrics --> <div class="grid grid-cols-2 gap-3 sm:grid-cols-4 sm:gap-6"> <!-- Total Units --> <div class="space-y-0.5"> <div class="text-muted-foreground text-xs font-medium">Total Units</div> <div class="font-mono text-base font-bold tabular-nums sm:text-lg"> {{ formatNumber(totalUnits()) }} Units </div> <div class="text-muted-foreground font-mono text-xs tabular-nums">{{ totalCases() }} Cases ordered</div> </div> <!-- Wholesale Subtotal --> <div class="space-y-0.5"> <div class="text-muted-foreground text-xs font-medium">Wholesale Subtotal</div> <div class="font-mono text-base font-bold tabular-nums sm:text-lg"> {{ formatCurrency(wholesaleSubtotal()) }} </div> <div class="text-muted-foreground text-xs">MSRP {{ formatCurrency(msrpTotal()) }}</div> </div> <!-- Tier Savings --> <div class="space-y-0.5"> <div class="text-muted-foreground text-xs font-medium">Tier Volume Savings</div> <div class="text-success text-success font-mono text-base font-bold tabular-nums sm:text-lg"> -{{ formatCurrency(totalSavings()) }} </div> <div class="text-success text-xs font-medium">{{ savingsPercent }}% Margin Savings</div> </div> <!-- Freight & Terms --> <div class="space-y-0.5"> <div class="text-muted-foreground text-xs font-medium">Freight & Terms</div> <div class="text-foreground flex items-center gap-1.5 text-xs font-semibold"> <lucide-icon [img]="Truck" class="text-primary size-3.5 shrink-0" /> <span>{{ isFreeFreight ? 'Free Freight (>$10k)' : '$250 Ground' }}</span> </div> <div class="text-muted-foreground text-xs">{{ paymentTerms }}</div> </div> </div> <!-- Submit & Secondary Actions --> <div class="flex flex-wrap items-center gap-2.5 pt-2 lg:pt-0"> <button ui-button aria-label="Download attachment" variant="outline" size="sm" class="text-xs font-medium" [disabled]="totalCases() === 0" (click)="handleExportCsv()" > <lucide-icon [img]="Download" [class]="'mr-1.5 size-3.5'" /> Export CSV </button> <button ui-button size="default" class="gap-2 text-xs font-semibold sm:text-sm" [disabled]="totalCases() === 0 || isSubmitting()" (click)="handleSubmitOrder()" > @if (!isSubmitting()) { <lucide-icon [img]="Send" class="size-4" /> } @else { <lucide-icon [img]="RefreshCw" class="size-4 animate-spin" /> } <span>{{ isSubmitting() ? 'Processing PO...' : 'Submit Wholesale Purchase Order' }}</span> </button> </div> </div> </div> `, }) export class UiWholesaleB2bQuickOrderComponent implements OnInit { @Input() poNumber = 'PO-2026-8841B' @Input() accountName = 'Northwind Retailers' @Input() accountTier = 'Tier 3 Wholesale Partner' @Input() tierDiscountPercent = 35 @Input() paymentTerms = 'Net 30 Invoicing' @Input() freeFreightThreshold = 10000 @Input() initialProducts?: WholesaleProduct[] @Input('class') className?: string @Output() submitOrder = new EventEmitter<WholesaleOrderPayload>() @Output() saveTemplate = new EventEmitter<WholesaleProduct[]>() @Output() exportCsv = new EventEmitter<WholesaleProduct[]>() protected readonly AlertCircle = AlertCircle protected readonly Bookmark = Bookmark protected readonly Building2 = Building2 protected readonly CheckCircle2 = CheckCircle2 protected readonly Download = Download protected readonly FileSpreadsheet = FileSpreadsheet protected readonly Layers = Layers protected readonly Minus = Minus protected readonly Package = Package protected readonly Plus = Plus protected readonly RefreshCw = RefreshCw protected readonly Send = Send protected readonly Trash2 = Trash2 protected readonly TrendingDown = TrendingDown protected readonly Truck = Truck protected readonly Upload = Upload protected readonly X = X protected readonly cn = cn readonly csvPlaceholder = SAMPLE_CSV readonly products = signal<WholesaleProduct[]>(clone(DEFAULT_WHOLESALE_PRODUCTS)) readonly poRef = signal('PO-2026-8841B') readonly showCsvBox = signal(false) readonly csvPasteText = signal('') readonly pasteFeedback = signal<WholesalePasteFeedback | null>(null) readonly templateSaved = signal(false) readonly orderSubmitted = signal(false) readonly isSubmitting = signal(false) private readonly timers = new Set<ReturnType<typeof setTimeout>>() constructor() { inject(DestroyRef).onDestroy(() => { for (const t of this.timers) clearTimeout(t) this.timers.clear() }) } /** React seeds its state once from the props; inputs are set by now. */ ngOnInit(): void { this.products.set(clone(this.initialProducts ?? DEFAULT_WHOLESALE_PRODUCTS)) this.poRef.set(this.poNumber) } get hostClass(): string { return cn('block bg-background text-foreground w-full space-y-6', this.className) } // Computations readonly totalCases = computed(() => this.products().reduce((acc, p) => acc + (Number(p.cases) || 0), 0)) readonly totalUnits = computed(() => this.products().reduce((acc, p) => acc + (Number(p.cases) || 0) * p.casePack, 0)) readonly wholesaleSubtotal = computed(() => this.products().reduce((acc, p) => acc + (Number(p.cases) || 0) * p.casePack * p.wholesaleUnitPrice, 0), ) readonly msrpTotal = computed(() => this.products().reduce((acc, p) => acc + (Number(p.cases) || 0) * p.casePack * p.msrpUnitPrice, 0), ) readonly totalSavings = computed(() => Math.max(0, this.msrpTotal() - this.wholesaleSubtotal())) get savingsPercent(): number { if (this.msrpTotal() <= 0) return this.tierDiscountPercent return Math.round((this.totalSavings() / this.msrpTotal()) * 100) } get isFreeFreight(): boolean { return this.wholesaleSubtotal() >= this.freeFreightThreshold } get grandTotal(): number { return this.wholesaleSubtotal() + (this.isFreeFreight ? 0 : 250) } private readonly nextTierTarget = 50000 readonly nextTierRemaining = computed(() => Math.max(0, this.nextTierTarget - this.wholesaleSubtotal())) readonly tierProgressPercent = computed(() => Math.min(100, Math.round((this.wholesaleSubtotal() / this.nextTierTarget) * 100)), ) formatCurrency(val: number): string { return currencyFormatter.format(val) } formatNumber(val: number): string { return numberFormatter.format(val) } discountPercent(p: WholesaleProduct): number { return Math.round(((p.msrpUnitPrice - p.wholesaleUnitPrice) / p.msrpUnitPrice) * 100) } // Actions updateCases(id: string, delta: number): void { this.products.update((prev) => prev.map((p) => { if (p.id !== id) return p const current = Number(p.cases) || 0 return { ...p, cases: Math.max(0, Math.min(p.availableCases, current + delta)) } }), ) } setCases(id: string, val: string | number): void { this.products.update((prev) => prev.map((p) => { if (p.id !== id) return p const parsed = parseInt(String(val), 10) return { ...p, cases: isNaN(parsed) || parsed < 0 ? 0 : Math.min(p.availableCases, parsed) } }), ) } clearLine(id: string): void { this.products.update((prev) => prev.map((p) => (p.id === id ? { ...p, cases: 0 } : p))) } resetAllCases(): void { this.products.update((prev) => prev.map((p) => ({ ...p, cases: 0 }))) this.pasteFeedback.set(null) } restoreDefaultCases(): void { this.products.set(clone(DEFAULT_WHOLESALE_PRODUCTS)) this.pasteFeedback.set(null) } applyCsvPaste(): void { this.pasteFeedback.set(null) const text = this.csvPasteText() if (!text.trim()) { this.pasteFeedback.set({ type: 'error', message: 'Please paste SKU and case quantity rows to parse.' }) return } let updatedCount = 0 const unknownSkus: string[] = [] const cloned = clone(this.products()) for (const rawLine of text.split(/\r?\n/)) { const line = rawLine.trim() if (!line || line.startsWith('#') || line.toLowerCase().startsWith('sku')) continue const parts = line.split(/[,;\t]+/).map((s) => s.trim()) if (parts.length < 2) continue const skuQuery = parts[0]!.toUpperCase() const qty = parseInt(parts[1]!, 10) if (isNaN(qty)) continue const product = cloned.find((p) => p.sku.toUpperCase() === skuQuery || p.sku.toUpperCase().includes(skuQuery)) if (product) { product.cases = Math.max(0, Math.min(product.availableCases, qty)) updatedCount++ } else { unknownSkus.push(parts[0]!) } } this.products.set(cloned) if (updatedCount > 0) { this.pasteFeedback.set({ type: 'success', message: `Successfully updated ${updatedCount} SKU line items in bulk matrix.${ unknownSkus.length > 0 ? ` Note: ${unknownSkus.length} SKU(s) not found in catalog (${unknownSkus.slice(0, 3).join(', ')}).` : '' }`, }) } else { this.pasteFeedback.set({ type: 'error', message: 'No matching catalog SKUs found. Verify format: SKU-84920, 10', }) } } loadSampleCsv(): void { this.csvPasteText.set(SAMPLE_CSV) const cloned = clone(this.products()) for (const rawLine of SAMPLE_CSV.split(/\r?\n/)) { const parts = rawLine.split(/[,;\t]+/).map((s) => s.trim()) if (parts.length < 2) continue const skuQuery = parts[0]!.toUpperCase() const qty = parseInt(parts[1]!, 10) const product = cloned.find((p) => p.sku.toUpperCase() === skuQuery) if (product && !isNaN(qty)) product.cases = Math.max(0, Math.min(product.availableCases, qty)) } this.products.set(cloned) this.pasteFeedback.set({ type: 'success', message: 'Sample manifest loaded and applied to matrix (5 SKUs updated).', }) } handleSaveTemplate(): void { this.templateSaved.set(true) this.saveTemplate.emit(this.products()) this.later(() => this.templateSaved.set(false), 4000) } handleSubmitOrder(): void { if (this.totalCases() === 0) return this.isSubmitting.set(true) this.later(() => { this.isSubmitting.set(false) this.orderSubmitted.set(true) this.submitOrder.emit({ poNumber: this.poRef(), items: this.products().filter((p) => p.cases > 0), subtotal: this.wholesaleSubtotal(), totalUnits: this.totalUnits(), totalCases: this.totalCases(), savings: this.totalSavings(), }) }, 600) } handleExportCsv(): void { const products = this.products() this.exportCsv.emit(products) const header = 'SKU,Product Name,Variant,Case Pack Multiplier,Cases Ordered,Total Units,Wholesale Unit Price,Line Total\n' const rows = products .filter((p) => p.cases > 0) .map( (p) => `"${p.sku}","${p.name}","${p.variant}",${p.casePack},${p.cases},${p.cases * p.casePack},${p.wholesaleUnitPrice},${(p.cases * p.casePack * p.wholesaleUnitPrice).toFixed(2)}`, ) .join('\n') const csvContent = 'data:text/csv;charset=utf-8,' + encodeURIComponent(header + rows) const downloadLink = document.createElement('a') downloadLink.setAttribute('href', csvContent) downloadLink.setAttribute('download', `${this.poRef()}-order-matrix.csv`) document.body.appendChild(downloadLink) downloadLink.click() document.body.removeChild(downloadLink) } private later(fn: () => void, ms: number): void { const t = setTimeout(() => { this.timers.delete(t) fn() }, ms) this.timers.add(t) } }src/app/components/blocks/wholesale-b2b-quick-order/index.ts0.2 kB
export { UiWholesaleB2bQuickOrderComponent, DEFAULT_WHOLESALE_PRODUCTS, type WholesaleOrderPayload, type WholesalePasteFeedback, type WholesaleProduct, } from './wholesale-b2b-quick-order.component'
Raw manifest:https://uipkge.dev/r/angular/wholesale-b2b-quick-order.json