Menu

Change language

GitHub repo

Wholesale B2b Quick Order

blockcommerce

B2B wholesale bulk order matrix with volume tier pricing, case pack multipliers, and CSV SKU paste input.

Installation

$npx uipkge-ng@latest add wholesale-b2b-quick-order
Manifest:https://uipkge.dev/r/angular/wholesale-b2b-quick-order.jsonInstalls to:src/app/components/blocks/wholesale-b2b-quick-order/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

WholesaleProduct
interface WholesaleProduct {
  id: string
  sku: string
  name: string
  variant: string
  casePack: number
  msrpUnitPrice: number
  wholesaleUnitPrice: number
  cases: number
  availableCases: number
  image: string
  category: string
}
WholesaleOrderPayload
interface WholesaleOrderPayload {
  poNumber: string
  items: WholesaleProduct[]
  subtotal: number
  totalUnits: number
  totalCases: number
  savings: number
}
WholesalePasteFeedback
interface 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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

Raw manifest:https://uipkge.dev/r/angular/wholesale-b2b-quick-order.json