Menu

Change language

GitHub repo

Inventory Reorder Forecast

blocklogistics

Supply chain inventory replenishment planner with lead-time demand forecasting, 4 forecasting KPI metric cards, safety stock triggers, multi-SKU demand table, and automated purchase order generation.

Installation

$npx uipkge-ng@latest add inventory-reorder-forecast
Manifest:https://uipkge.dev/r/angular/inventory-reorder-forecast.jsonInstalls to:src/app/components/blocks/inventory-reorder-forecast/

Variants

Loading interactive previews…

Schema

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

ReorderItem
interface ReorderItem {
  id: string
  sku: string
  name: string
  category: string
  supplier: string
  onHandStock: number
  safetyStockThreshold: number
  dailySalesVelocity: number
  velocityTrend: number
  daysOfInventoryRemaining: number
  supplierLeadTimeDays: number
  recommendedReorderQty: number
  unitCost: number
  estimatedPoCost: number
  status: StockHealthStatus
}

Files installed (2)

  • src/app/components/blocks/inventory-reorder-forecast/inventory-reorder-forecast.component.ts38.3 kB
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      AlertTriangle,
      ArrowUpRight,
      Boxes,
      Building2,
      CheckCircle2,
      Clock,
      DollarSign,
      Download,
      FileCheck,
      LucideAngularModule,
      PackageCheck,
      PackagePlus,
      Search,
      TrendingUp,
      X,
      Zap,
    } 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,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import {
      UiDialogCloseComponent,
      UiDialogComponent,
      UiDialogContentComponent,
      UiDialogDescriptionComponent,
      UiDialogFooterComponent,
      UiDialogHeaderComponent,
      UiDialogTitleComponent,
    } from '@/ui/dialog/dialog.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectItemComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    
    export type StockHealthStatus = 'critical' | 'warning' | 'healthy'
    
    export interface ReorderItem {
      id: string
      sku: string
      name: string
      category: string
      supplier: string
      onHandStock: number
      safetyStockThreshold: number
      dailySalesVelocity: number
      velocityTrend: number
      daysOfInventoryRemaining: number
      supplierLeadTimeDays: number
      recommendedReorderQty: number
      unitCost: number
      estimatedPoCost: number
      status: StockHealthStatus
    }
    
    const reorderItems: ReorderItem[] = [
      {
        id: 'sku-1',
        sku: 'SKU-RN-105-BLK',
        name: 'Aero Minimalist Runner 10.5',
        category: 'Footwear',
        supplier: 'Apex Footwear Ltd',
        onHandStock: 42,
        safetyStockThreshold: 80,
        dailySalesVelocity: 12.4,
        velocityTrend: 14.2,
        daysOfInventoryRemaining: 3.4,
        supplierLeadTimeDays: 14,
        recommendedReorderQty: 250,
        unitCost: 50.0,
        estimatedPoCost: 12500.0,
        status: 'critical',
      },
      {
        id: 'sku-2',
        sku: 'SKU-PK-082-SLT',
        name: 'Technical Shell Parka L',
        category: 'Outerwear',
        supplier: 'Nordic Outerwear Co',
        onHandStock: 18,
        safetyStockThreshold: 50,
        dailySalesVelocity: 4.5,
        velocityTrend: 8.5,
        daysOfInventoryRemaining: 4.0,
        supplierLeadTimeDays: 21,
        recommendedReorderQty: 120,
        unitCost: 145.0,
        estimatedPoCost: 17400.0,
        status: 'critical',
      },
      {
        id: 'sku-3',
        sku: 'SKU-HD-419-PRO',
        name: 'Pro Studio Headphones',
        category: 'Electronics',
        supplier: 'Sonic Acoustics Inc',
        onHandStock: 65,
        safetyStockThreshold: 85,
        dailySalesVelocity: 8.2,
        velocityTrend: 5.0,
        daysOfInventoryRemaining: 7.9,
        supplierLeadTimeDays: 10,
        recommendedReorderQty: 100,
        unitCost: 110.0,
        estimatedPoCost: 11000.0,
        status: 'warning',
      },
      {
        id: 'sku-4',
        sku: 'SKU-CD-904-BRN',
        name: 'Leather Cardholder',
        category: 'Accessories',
        supplier: 'Tuscan Goods Ltd',
        onHandStock: 88,
        safetyStockThreshold: 100,
        dailySalesVelocity: 6.0,
        velocityTrend: 2.1,
        daysOfInventoryRemaining: 14.7,
        supplierLeadTimeDays: 12,
        recommendedReorderQty: 150,
        unitCost: 32.0,
        estimatedPoCost: 4800.0,
        status: 'warning',
      },
      {
        id: 'sku-5',
        sku: 'SKU-WC-331-WHT',
        name: 'Wireless Charger Mat',
        category: 'Electronics',
        supplier: 'Volt Innovations',
        onHandStock: 340,
        safetyStockThreshold: 120,
        dailySalesVelocity: 8.1,
        velocityTrend: -1.4,
        daysOfInventoryRemaining: 42.0,
        supplierLeadTimeDays: 7,
        recommendedReorderQty: 100,
        unitCost: 25.5,
        estimatedPoCost: 2550.0,
        status: 'healthy',
      },
    ]
    
    /**
     * InventoryReorderForecast -- the React block, 1:1: a supply chain inventory
     * replenishment planner with a facility selector, 4 forecasting KPI metric cards, safety
     * stock triggers, a multi-SKU demand table with search + category + health filters, and
     * single/bulk purchase order generation dialogs with a feedback banner.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-inventory-reorder-forecast, [ui-inventory-reorder-forecast]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiDialogCloseComponent,
        UiDialogComponent,
        UiDialogContentComponent,
        UiDialogDescriptionComponent,
        UiDialogFooterComponent,
        UiDialogHeaderComponent,
        UiDialogTitleComponent,
        UiInputComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
        UiSeparatorComponent,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
      ],
      template: `
        <div data-slot="inventory-reorder-forecast" [class]="rootClass">
          <!-- Header Section -->
          <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
            <div class="space-y-1">
              <div class="flex flex-wrap items-center gap-2.5">
                <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-lg">
                  <lucide-icon [img]="Boxes" class="size-4.5" />
                </div>
                <h1 class="text-foreground text-xl font-bold tracking-tight sm:text-2xl">
                  Inventory Demand & Reorder Forecast
                </h1>
                <span ui-badge variant="warning" class="border-warning/30 bg-warning/10 text-warning gap-1.5">
                  <span class="bg-warning size-1.5 animate-pulse rounded-full"></span>
                  4 SKUs Need Reorder
                </span>
              </div>
              <p class="text-muted-foreground text-xs sm:text-sm">
                Multi-channel supply chain replenishment planner with lead-time demand forecasting and automated purchase
                order generation.
              </p>
            </div>
    
            <div class="flex flex-wrap items-center gap-2.5">
              <!-- Facility Selector -->
              <ui-select [value]="facility()" (valueChange)="facility.set($event)">
                <button ui-select-trigger class="w-full sm:w-[280px]">
                  <lucide-icon [img]="Building2" class="text-muted-foreground size-4 shrink-0" />
                  <ui-select-value placeholder="Select fulfillment facility" />
                </button>
                <ui-select-content>
                  <ui-select-item value="global-fulfillment">Global Fulfillment Center</ui-select-item>
                  <ui-select-item value="na-west-hub">North America West Distribution Hub</ui-select-item>
                  <ui-select-item value="eu-central-dc">European Central Logistics Depot</ui-select-item>
                  <ui-select-item value="apac-regional">APAC Regional Hub · Singapore</ui-select-item>
                </ui-select-content>
              </ui-select>
    
              <!-- Export Plan Button -->
              <button
                ui-button
                aria-label="Download attachment"
                variant="outline"
                class="gap-1.5 shadow-xs"
                (click)="handleExportPlan()"
              >
                <lucide-icon [img]="Download" class="size-4" />
                Export Replenishment Plan
              </button>
    
              <!-- Primary Action: Generate Bulk POs -->
              <button ui-button class="gap-1.5 shadow-xs" (click)="openBulkPoDialog()">
                <lucide-icon [img]="PackagePlus" class="size-4" />
                Generate Bulk POs
              </button>
            </div>
          </div>
    
          <!-- Notification / Action Feedback Banner -->
          @if (actionFeedback()) {
            <div
              class="border-primary/20 bg-primary/5 text-foreground flex items-center justify-between rounded-lg border px-4 py-3 text-sm shadow-xs transition-colors"
            >
              <div class="flex items-center gap-2.5">
                <lucide-icon [img]="CheckCircle2" class="text-success size-4 shrink-0" />
                <span class="text-xs font-medium sm:text-sm">{{ actionFeedback() }}</span>
              </div>
              <button
                ui-button
                variant="ghost"
                size="xs"
                class="h-6 w-6 p-0"
                aria-label="Dismiss notification"
                (click)="actionFeedback.set(null)"
              >
                <lucide-icon [img]="X" class="size-3.5" />
              </button>
            </div>
          }
    
          <!-- 4 Forecasting KPI Cards -->
          <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
            <!-- Metric 1: Critical Reorder SKUs -->
            <div ui-card class="shadow-xs">
              <div ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
                <h3 ui-card-title class="text-muted-foreground text-xs font-medium tracking-wider uppercase">
                  Critical Reorder SKUs
                </h3>
                <div class="bg-warning/10 text-warning flex size-8 items-center justify-center rounded-md">
                  <lucide-icon [img]="AlertTriangle" class="size-4" />
                </div>
              </div>
              <div ui-card-content class="space-y-1">
                <div class="flex items-baseline gap-2">
                  <div class="text-warning text-warning text-2xl font-bold tracking-tight tabular-nums">
                    4 <span class="text-muted-foreground text-sm font-normal">SKUs</span>
                  </div>
                  <span ui-badge variant="warning" class="text-xs font-normal">Action Required</span>
                </div>
                <p class="text-muted-foreground pt-1 text-xs">Below safety threshold · 2 in urgent stockout zone</p>
              </div>
            </div>
    
            <!-- Metric 2: Estimated Stockout Days -->
            <div ui-card class="shadow-xs">
              <div ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
                <h3 ui-card-title class="text-muted-foreground text-xs font-medium tracking-wider uppercase">
                  Estimated Stockout Days
                </h3>
                <div class="bg-destructive/10 text-destructive flex size-8 items-center justify-center rounded-md">
                  <lucide-icon [img]="Clock" class="size-4" />
                </div>
              </div>
              <div ui-card-content class="space-y-1">
                <div class="flex items-baseline gap-2">
                  <div class="text-foreground text-2xl font-bold tracking-tight tabular-nums">
                    6.4 <span class="text-muted-foreground text-sm font-normal">Days</span>
                  </div>
                  <span ui-badge variant="destructive" class="text-xs font-normal">Critical Risk</span>
                </div>
                <p class="text-muted-foreground pt-1 text-xs">6.4 Days until stockout on top SKU · Urgent PO needed</p>
              </div>
            </div>
    
            <!-- Metric 3: Suggested Purchase Order Value -->
            <div ui-card class="shadow-xs">
              <div ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
                <h3 ui-card-title class="text-muted-foreground text-xs font-medium tracking-wider uppercase">
                  Suggested PO Value
                </h3>
                <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-md">
                  <lucide-icon [img]="DollarSign" class="size-4" />
                </div>
              </div>
              <div ui-card-content class="space-y-1">
                <div class="text-foreground text-2xl font-bold tracking-tight tabular-nums">$48,250.00</div>
                <p class="text-muted-foreground flex items-center gap-1.5 pt-1 text-xs">
                  <span class="text-success flex items-center font-medium">
                    <lucide-icon [img]="Zap" class="mr-1 inline size-3" />4 replenishment POs
                  </span>
                  <span>· 620 units total</span>
                </p>
              </div>
            </div>
    
            <!-- Metric 4: Inventory Turnover Rate -->
            <div ui-card class="shadow-xs">
              <div ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
                <h3 ui-card-title class="text-muted-foreground text-xs font-medium tracking-wider uppercase">
                  Inventory Turnover Rate
                </h3>
                <div class="bg-success/10 text-success flex size-8 items-center justify-center rounded-md">
                  <lucide-icon [img]="TrendingUp" class="size-4" />
                </div>
              </div>
              <div ui-card-content class="space-y-1">
                <div class="text-foreground text-2xl font-bold tracking-tight tabular-nums">
                  8.4x <span class="text-muted-foreground text-sm font-normal">/ year</span>
                </div>
                <p class="text-muted-foreground flex items-center gap-1.5 pt-1 text-xs">
                  <span class="text-success flex items-center font-medium">
                    <lucide-icon [img]="ArrowUpRight" class="mr-0.5 inline size-3" />+1.2x
                  </span>
                  <span>vs industry benchmark (7.2x)</span>
                </p>
              </div>
            </div>
          </div>
    
          <!-- Reorder Demand Forecast Table Toolbar & Card -->
          <div ui-card class="shadow-xs">
            <div ui-card-header class="border-border border-b pb-3">
              <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
                <div class="flex flex-1 flex-wrap items-center gap-2">
                  <div class="relative w-full max-w-sm">
                    <lucide-icon
                      [img]="Search"
                      class="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2"
                    />
                    <ui-input
                      [value]="searchQuery()"
                      (valueChange)="searchQuery.set($event)"
                      placeholder="Search SKU, product name, supplier..."
                      class="h-9 pl-9 text-xs sm:text-sm"
                    />
                  </div>
    
                  <!-- Category Filter -->
                  <ui-select [value]="categoryFilter()" (valueChange)="categoryFilter.set($event)">
                    <button ui-select-trigger class="h-9 w-[160px] text-xs">
                      <ui-select-value placeholder="All Categories" />
                    </button>
                    <ui-select-content>
                      <ui-select-item value="all">All Categories</ui-select-item>
                      <ui-select-item value="Footwear">Footwear</ui-select-item>
                      <ui-select-item value="Outerwear">Outerwear</ui-select-item>
                      <ui-select-item value="Electronics">Electronics</ui-select-item>
                      <ui-select-item value="Accessories">Accessories</ui-select-item>
                    </ui-select-content>
                  </ui-select>
    
                  <!-- Status Filter -->
                  <ui-select [value]="statusFilter()" (valueChange)="statusFilter.set($event)">
                    <button ui-select-trigger class="h-9 w-[170px] text-xs">
                      <ui-select-value placeholder="All Health Status" />
                    </button>
                    <ui-select-content>
                      <ui-select-item value="all">All Stock Status</ui-select-item>
                      <ui-select-item value="critical">Critical Stockout (&lt;7d)</ui-select-item>
                      <ui-select-item value="warning">Reorder Warning (7-15d)</ui-select-item>
                      <ui-select-item value="healthy">Healthy Stock (&gt;30d)</ui-select-item>
                    </ui-select-content>
                  </ui-select>
                </div>
    
                <div class="text-muted-foreground flex items-center gap-2 text-xs">
                  <span>
                    Showing <strong class="text-foreground tabular-nums">{{ filteredItems().length }}</strong> of
                    {{ allItems.length }} forecast SKUs
                  </span>
                </div>
              </div>
            </div>
    
            <div ui-card-content class="p-0">
              <!-- Demand Table -->
              <div class="overflow-x-auto">
                <table ui-table>
                  <thead ui-table-header>
                    <tr ui-table-row class="hover:bg-transparent">
                      <th ui-table-head class="w-[240px] text-xs font-semibold">SKU Identifier & Product</th>
                      <th ui-table-head class="text-right text-xs font-semibold">Current On-Hand</th>
                      <th ui-table-head class="text-right text-xs font-semibold">Sales Velocity</th>
                      <th ui-table-head class="min-w-[150px] text-xs font-semibold">Days Remaining</th>
                      <th ui-table-head class="text-right text-xs font-semibold">Supplier Lead Time</th>
                      <th ui-table-head class="text-right text-xs font-semibold">Recommended Qty</th>
                      <th ui-table-head class="text-right text-xs font-semibold">Est. PO Cost</th>
                      <th ui-table-head class="w-[120px] text-right text-xs font-semibold">Quick Action</th>
                    </tr>
                  </thead>
                  <tbody ui-table-body>
                    @for (item of filteredItems(); track item.id) {
                      <tr ui-table-row class="hover:bg-muted/40 transition-colors">
                        <!-- SKU Identifier & Product Name -->
                        <td ui-table-cell class="py-3">
                          <div class="space-y-1">
                            <div class="text-foreground font-mono text-xs font-semibold">{{ item.sku }}</div>
                            <div class="text-foreground text-sm font-medium">{{ item.name }}</div>
                            <div class="text-muted-foreground flex items-center gap-1.5 text-xs">
                              <span>{{ item.category }}</span>
                              <span>·</span>
                              <span>{{ item.supplier }}</span>
                            </div>
                          </div>
                        </td>
    
                        <!-- Current On-Hand Stock -->
                        <td ui-table-cell class="py-3 text-right">
                          <div class="text-foreground text-sm font-semibold tabular-nums">
                            {{ item.onHandStock.toLocaleString() }}
                            <span class="text-muted-foreground text-xs font-normal">units</span>
                          </div>
                          <div class="text-muted-foreground text-xs tabular-nums">
                            Safety: {{ item.safetyStockThreshold }} units
                          </div>
                        </td>
    
                        <!-- Average Daily Sales Velocity -->
                        <td ui-table-cell class="py-3 text-right">
                          <div class="text-foreground text-sm font-semibold tabular-nums">
                            {{ item.dailySalesVelocity.toFixed(1) }}
                            <span class="text-muted-foreground text-xs font-normal">units/day</span>
                          </div>
                          <div
                            class="text-xs font-medium tabular-nums"
                            [class]="item.velocityTrend >= 0 ? 'text-success' : 'text-destructive'"
                          >
                            {{ item.velocityTrend >= 0 ? '+' : '' }}{{ item.velocityTrend.toFixed(1) }}% 7d
                          </div>
                        </td>
    
                        <!-- Days of Inventory Remaining Badge -->
                        <td ui-table-cell class="py-3">
                          <div class="space-y-1.5">
                            @if (item.status === 'critical') {
                              <span ui-badge variant="destructive" class="gap-1 font-mono text-xs font-semibold">
                                <lucide-icon [img]="AlertTriangle" class="size-3" />
                                {{ item.daysOfInventoryRemaining.toFixed(1) }} Days
                              </span>
                            } @else if (item.status === 'warning') {
                              <span ui-badge variant="warning" class="text-warning gap-1 font-mono text-xs font-semibold">
                                <lucide-icon [img]="Clock" class="size-3" />
                                {{ item.daysOfInventoryRemaining.toFixed(1) }} Days
                              </span>
                            } @else {
                              <span ui-badge variant="success" class="gap-1 font-mono text-xs font-semibold">
                                <lucide-icon [img]="CheckCircle2" class="size-3" />
                                {{ item.daysOfInventoryRemaining.toFixed(1) }} Days
                              </span>
                            }
                            <div class="text-muted-foreground text-xs">
                              {{
                                item.status === 'critical'
                                  ? 'Stockout before delivery'
                                  : item.status === 'warning'
                                    ? 'Reorder buffer thin'
                                    : 'Healthy inventory level'
                              }}
                            </div>
                          </div>
                        </td>
    
                        <!-- Supplier Lead Time -->
                        <td ui-table-cell class="py-3 text-right">
                          <div class="text-foreground font-mono text-sm font-semibold tabular-nums">
                            {{ item.supplierLeadTimeDays }}
                            <span class="text-muted-foreground text-xs font-normal">Days</span>
                          </div>
                          <div class="text-muted-foreground text-xs">Mfg & Freight</div>
                        </td>
    
                        <!-- Recommended Reorder Qty -->
                        <td ui-table-cell class="py-3 text-right">
                          <div class="text-foreground text-sm font-bold tabular-nums">
                            {{ item.recommendedReorderQty }}
                            <span class="text-muted-foreground text-xs font-normal">units</span>
                          </div>
                          <div class="text-muted-foreground text-xs">Optimal batch</div>
                        </td>
    
                        <!-- Estimated PO Cost -->
                        <td ui-table-cell class="py-3 text-right">
                          <div class="text-foreground font-mono text-sm font-bold tabular-nums">
                            {{ money(item.estimatedPoCost) }}
                          </div>
                          <div class="text-muted-foreground text-xs tabular-nums">{{ money(item.unitCost) }} / unit</div>
                        </td>
    
                        <!-- Quick Action Button -->
                        <td ui-table-cell class="py-3 text-right">
                          <div class="flex items-center justify-end gap-1.5">
                            <button
                              ui-button
                              size="sm"
                              class="gap-1 shadow-xs"
                              [variant]="item.status === 'critical' ? 'default' : 'outline'"
                              (click)="openCreatePoDialog(item)"
                            >
                              <lucide-icon [img]="PackageCheck" class="size-3.5" />
                              Create PO
                            </button>
                          </div>
                        </td>
                      </tr>
                    }
    
                    @if (filteredItems().length === 0) {
                      <tr ui-table-row>
                        <td ui-table-cell [attr.colspan]="8" class="text-muted-foreground h-32 text-center text-sm">
                          No inventory forecast items match your current filter criteria.
                        </td>
                      </tr>
                    }
                  </tbody>
                </table>
              </div>
            </div>
          </div>
    
          <!-- Create Purchase Order Draft Dialog -->
          <ui-dialog [open]="createPoDialogOpen()" (openChange)="createPoDialogOpen.set($event)">
            <ui-dialog-content class="sm:max-w-lg">
              <div ui-dialog-header>
                <div class="flex items-center gap-2">
                  <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-md">
                    <lucide-icon [img]="PackageCheck" class="size-4" />
                  </div>
                  <h2 ui-dialog-title class="text-lg font-bold">Draft Purchase Order</h2>
                </div>
                <p ui-dialog-description class="text-muted-foreground text-xs">
                  Review demand velocity, set purchase order quantity, and dispatch replenishment order to supplier.
                </p>
              </div>
    
              @if (selectedSkuItem(); as selected) {
                <div class="space-y-4 py-2">
                  <!-- SKU Summary Banner -->
                  <div class="border-border bg-muted/20 space-y-3 rounded-lg border p-3.5">
                    <div class="flex items-start justify-between gap-2">
                      <div>
                        <div class="text-primary font-mono text-xs font-semibold">{{ selected.sku }}</div>
                        <div class="text-foreground text-sm font-semibold">{{ selected.name }}</div>
                        <div class="text-muted-foreground text-xs">{{ selected.supplier }} · {{ selected.category }}</div>
                      </div>
                      <span
                        ui-badge
                        [variant]="
                          selected.status === 'critical'
                            ? 'destructive'
                            : selected.status === 'warning'
                              ? 'warning'
                              : 'success'
                        "
                        class="text-xs"
                      >
                        {{ selected.daysOfInventoryRemaining }} Days Stock Left
                      </span>
                    </div>
    
                    <div ui-separator></div>
    
                    <div class="grid grid-cols-3 gap-2 text-xs">
                      <div>
                        <span class="text-muted-foreground">On Hand:</span>
                        <p class="text-foreground font-semibold tabular-nums">{{ selected.onHandStock }} units</p>
                      </div>
                      <div>
                        <span class="text-muted-foreground">Daily Velocity:</span>
                        <p class="text-foreground font-semibold tabular-nums">{{ selected.dailySalesVelocity }} / day</p>
                      </div>
                      <div>
                        <span class="text-muted-foreground">Lead Time:</span>
                        <p class="text-foreground font-semibold tabular-nums">{{ selected.supplierLeadTimeDays }} Days</p>
                      </div>
                    </div>
                  </div>
    
                  <!-- Quantity Input & Quick Chips -->
                  <div class="space-y-1.5">
                    <div class="flex items-center justify-between">
                      <label class="text-foreground text-xs font-medium">Reorder Quantity (Units)</label>
                      <span class="text-muted-foreground text-xs">
                        Recommended: {{ selected.recommendedReorderQty }} units
                      </span>
                    </div>
                    <ui-input
                      [value]="customReorderQty()"
                      (valueChange)="setCustomQty($event)"
                      type="number"
                      class="text-xs tabular-nums sm:text-sm"
                    />
                    <div class="flex gap-1.5 pt-1">
                      <button
                        type="button"
                        ui-button
                        variant="outline"
                        size="xs"
                        class="flex-1 text-xs"
                        (click)="setQtyFraction(selected, 0.5)"
                      >
                        50% ({{ halfQty(selected) }})
                      </button>
                      <button
                        type="button"
                        ui-button
                        variant="outline"
                        size="xs"
                        class="flex-1 text-xs"
                        (click)="setQtyFraction(selected, 1)"
                      >
                        Recommended ({{ selected.recommendedReorderQty }})
                      </button>
                      <button
                        type="button"
                        ui-button
                        variant="outline"
                        size="xs"
                        class="flex-1 text-xs"
                        (click)="setQtyFraction(selected, 1.5)"
                      >
                        150% ({{ oneHalfQty(selected) }})
                      </button>
                    </div>
                  </div>
    
                  <!-- Shipping Freight Method -->
                  <div class="space-y-1.5">
                    <label class="text-foreground text-xs font-medium">Inbound Freight Method</label>
                    <ui-select [value]="selectedShippingMethod()" (valueChange)="selectedShippingMethod.set($event)">
                      <button ui-select-trigger class="w-full text-xs">
                        <ui-select-value placeholder="Select shipping method" />
                      </button>
                      <ui-select-content>
                        <ui-select-item value="standard-freight">
                          Standard Consolidated Freight (Est. 14 Days)
                        </ui-select-item>
                        <ui-select-item value="expedited-air">Expedited Air Express (Est. 4 Days · +$420)</ui-select-item>
                        <ui-select-item value="ocean-container">
                          Full Ocean Container FCL (Est. 28 Days · Economy)
                        </ui-select-item>
                      </ui-select-content>
                    </ui-select>
                  </div>
    
                  <!-- Cost Calculation Summary Box -->
                  <div class="border-border bg-muted/40 space-y-2 rounded-lg border p-3.5 text-xs">
                    <div class="flex items-center justify-between">
                      <span class="text-muted-foreground">Unit Cost:</span>
                      <span class="text-foreground font-mono tabular-nums">{{ money(selected.unitCost) }}</span>
                    </div>
                    <div class="flex items-center justify-between">
                      <span class="text-muted-foreground">Units to Order:</span>
                      <span class="text-foreground font-mono tabular-nums">{{ customReorderQty() }} units</span>
                    </div>
                    <div ui-separator></div>
                    <div class="flex items-center justify-between text-sm font-semibold">
                      <span class="text-foreground">Total Estimated PO Value:</span>
                      <span class="text-primary font-mono text-base font-bold tabular-nums">
                        {{ money(dialogCalculatedCost()) }}
                      </span>
                    </div>
                  </div>
                </div>
              }
    
              <div ui-dialog-footer class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
                <button ui-button ui-dialog-close variant="outline">Cancel</button>
                <button ui-button class="gap-1.5" (click)="handleConfirmCreatePo()">
                  <lucide-icon [img]="FileCheck" class="size-4" />
                  Generate Purchase Order
                </button>
              </div>
            </ui-dialog-content>
          </ui-dialog>
    
          <!-- Bulk POs Generation Dialog -->
          <ui-dialog [open]="bulkPoDialogOpen()" (openChange)="bulkPoDialogOpen.set($event)">
            <ui-dialog-content class="sm:max-w-lg">
              <div ui-dialog-header>
                <div class="flex items-center gap-2">
                  <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-md">
                    <lucide-icon [img]="PackagePlus" class="size-4" />
                  </div>
                  <h2 ui-dialog-title class="text-lg font-bold">Generate Bulk Purchase Orders</h2>
                </div>
                <p ui-dialog-description class="text-muted-foreground text-xs">
                  Automatically create and batch replenishment purchase orders for all SKUs below safe safety threshold.
                </p>
              </div>
    
              <div class="space-y-4 py-2">
                <!-- Summary Metrics -->
                <div class="border-border bg-muted/20 space-y-3 rounded-lg border p-4">
                  <div class="flex items-center justify-between">
                    <span class="text-muted-foreground text-xs">Destination Facility:</span>
                    <span ui-badge variant="outline" class="text-xs font-medium">Global Fulfillment Center</span>
                  </div>
                  <div class="flex items-center justify-between">
                    <span class="text-muted-foreground text-xs">Total Purchase Orders:</span>
                    <span class="text-foreground text-xs font-semibold tabular-nums">4 Vendor POs</span>
                  </div>
                  <div class="flex items-center justify-between">
                    <span class="text-muted-foreground text-xs">Total Replenishment Units:</span>
                    <span class="text-foreground text-xs font-semibold tabular-nums">620 Units</span>
                  </div>
                  <div ui-separator></div>
                  <div class="flex items-center justify-between">
                    <span class="text-foreground text-xs font-semibold">Combined Total PO Value:</span>
                    <span class="text-primary font-mono text-base font-bold tabular-nums">$48,250.00</span>
                  </div>
                </div>
    
                <!-- PO List Breakdown -->
                <div class="space-y-2">
                  <div class="text-foreground text-xs font-semibold">Purchase Order Breakdown by Supplier</div>
                  <div class="border-border space-y-2 rounded-md border p-2.5 text-xs">
                    @for (item of criticalAndWarningItems(); track item.id) {
                      <div class="flex items-center justify-between py-1">
                        <div>
                          <span class="text-foreground font-medium">{{ item.supplier }}</span>
                          <div class="text-muted-foreground font-mono text-xs">
                            {{ item.sku }} ({{ item.recommendedReorderQty }} units)
                          </div>
                        </div>
                        <span class="text-foreground font-mono font-semibold tabular-nums">
                          {{ money(item.estimatedPoCost) }}
                        </span>
                      </div>
                    }
                  </div>
                </div>
              </div>
    
              <div ui-dialog-footer class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
                <button ui-button ui-dialog-close variant="outline">Cancel</button>
                <button ui-button class="gap-1.5" (click)="handleConfirmBulkPos()">
                  <lucide-icon [img]="PackagePlus" class="size-4" />
                  Generate 4 Purchase Orders
                </button>
              </div>
            </ui-dialog-content>
          </ui-dialog>
        </div>
      `,
    })
    export class UiInventoryReorderForecastComponent {
      protected readonly AlertTriangle = AlertTriangle
      protected readonly ArrowUpRight = ArrowUpRight
      protected readonly Boxes = Boxes
      protected readonly Building2 = Building2
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Clock = Clock
      protected readonly DollarSign = DollarSign
      protected readonly Download = Download
      protected readonly FileCheck = FileCheck
      protected readonly PackageCheck = PackageCheck
      protected readonly PackagePlus = PackagePlus
      protected readonly Search = Search
      protected readonly TrendingUp = TrendingUp
      protected readonly X = X
      protected readonly Zap = Zap
    
      protected readonly allItems = reorderItems
    
      @Input('class') className?: string
    
      readonly facility = signal('global-fulfillment')
      readonly searchQuery = signal('')
      readonly categoryFilter = signal('all')
      readonly statusFilter = signal('all')
    
      readonly createPoDialogOpen = signal(false)
      readonly bulkPoDialogOpen = signal(false)
      readonly selectedSkuItem = signal<ReorderItem | null>(null)
      readonly customReorderQty = signal<number>(250)
      readonly selectedShippingMethod = signal('standard-freight')
    
      readonly actionFeedback = signal<string | null>(null)
    
      readonly filteredItems = computed(() => {
        return reorderItems.filter((item) => {
          const q = this.searchQuery().trim().toLowerCase()
          const matchesSearch =
            !q ||
            item.name.toLowerCase().includes(q) ||
            item.sku.toLowerCase().includes(q) ||
            item.category.toLowerCase().includes(q) ||
            item.supplier.toLowerCase().includes(q)
    
          const matchesCategory = this.categoryFilter() === 'all' || item.category === this.categoryFilter()
          const matchesStatus = this.statusFilter() === 'all' || item.status === this.statusFilter()
    
          return matchesSearch && matchesCategory && matchesStatus
        })
      })
    
      readonly criticalAndWarningItems = computed(() => {
        return reorderItems.filter((item) => item.status === 'critical' || item.status === 'warning')
      })
    
      readonly dialogCalculatedCost = computed(() => {
        const selected = this.selectedSkuItem()
        if (!selected) return 0
        return this.customReorderQty() * selected.unitCost
      })
    
      get rootClass(): string {
        return cn('w-full space-y-6', this.className)
      }
    
      money(value: number): string {
        return '$' + value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
      }
    
      setCustomQty(raw: string): void {
        this.customReorderQty.set(Number(raw) || 0)
      }
    
      setQtyFraction(item: ReorderItem, fraction: number): void {
        this.customReorderQty.set(Math.round(item.recommendedReorderQty * fraction))
      }
    
      halfQty(item: ReorderItem): number {
        return Math.round(item.recommendedReorderQty * 0.5)
      }
    
      oneHalfQty(item: ReorderItem): number {
        return Math.round(item.recommendedReorderQty * 1.5)
      }
    
      openCreatePoDialog(item: ReorderItem): void {
        this.selectedSkuItem.set(item)
        this.customReorderQty.set(item.recommendedReorderQty)
        this.selectedShippingMethod.set('standard-freight')
        this.createPoDialogOpen.set(true)
      }
    
      handleConfirmCreatePo(): void {
        const selected = this.selectedSkuItem()
        if (!selected) return
        const poNumber = `PO-2026-${Math.floor(1000 + Math.random() * 9000)}`
        this.actionFeedback.set(
          `Draft ${poNumber} created for ${selected.sku} (${this.customReorderQty()} units · $${this.dialogCalculatedCost().toLocaleString('en-US', { minimumFractionDigits: 2 })}) routed to ${selected.supplier}.`,
        )
        this.createPoDialogOpen.set(false)
        setTimeout(() => this.actionFeedback.set(null), 4500)
      }
    
      openBulkPoDialog(): void {
        this.bulkPoDialogOpen.set(true)
      }
    
      handleConfirmBulkPos(): void {
        this.actionFeedback.set(
          `Successfully generated 4 Purchase Orders (Total $48,250.00) for Global Fulfillment Center replenishment.`,
        )
        this.bulkPoDialogOpen.set(false)
        setTimeout(() => this.actionFeedback.set(null), 5000)
      }
    
      handleExportPlan(): void {
        this.actionFeedback.set(`Replenishment forecast plan for ${reorderItems.length} SKUs exported successfully as CSV.`)
        setTimeout(() => this.actionFeedback.set(null), 4000)
      }
    }
    
  • src/app/components/blocks/inventory-reorder-forecast/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/inventory-reorder-forecast.json