Menu

Change language

GitHub repo

Stock Transfer Manager

blockdashboard

Inter-facility inventory transfer console with bin-level routing, barcode lookup, and condition classification.

Installation

$npx uipkge-ng@latest add stock-transfer-manager
Manifest:https://uipkge.dev/r/angular/stock-transfer-manager.jsonInstalls to:src/app/components/blocks/stock-transfer-manager/

Variants

Loading interactive previews…

Schema

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

TransferItem
interface TransferItem {
  id: string
  sku: string
  name: string
  barcode: string
  lotNumber: string
  sourceBin: string
  targetBin: string
  availableQty: number
  transferQty: number
  unit: string
  condition: TransferItemCondition
}

Files installed (2)

  • src/app/components/blocks/stock-transfer-manager/stock-transfer-manager.component.ts17 kB
    import { Component, Input, OnInit, computed, signal, ChangeDetectionStrategy } from '@angular/core'
    import {
      ArrowRight,
      Barcode,
      CheckCircle2,
      LucideAngularModule,
      Plus,
      QrCode,
      Trash2,
      Truck,
      Warehouse,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent, type BadgeVariant } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardFooterComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectItemComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    import {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    
    export type TransferItemCondition = 'sellable' | 'quarantine' | 'damaged'
    export type StockTransferStatus = 'draft' | 'staged' | 'in_transit' | 'received'
    
    export interface TransferItem {
      id: string
      sku: string
      name: string
      barcode: string
      lotNumber: string
      sourceBin: string
      targetBin: string
      availableQty: number
      transferQty: number
      unit: string
      condition: TransferItemCondition
    }
    
    export interface StockTransferProps {
      transferId?: string
      initialStatus?: StockTransferStatus
      className?: string
    }
    
    /**
     * Angular port of the React StockTransferManager block: a transfer header card (id, status
     * badge, dispatch / receipt / reset action, origin / destination / shipping selects) above a
     * manifest table with bin routing, editable transfer quantities and item conditions. The
     * scan box adds one unit to the line whose barcode or SKU matches. Edit the sample
     * manifest below.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-stock-transfer-manager, [ui-stock-transfer-manager]',
      standalone: true,
      host: {
        '[attr.data-slot]': '"stock-transfer-manager"',
        '[class]': 'hostClass',
      },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardFooterComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiInputComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
      ],
      template: `
        <!-- Header Summary Card -->
        <div ui-card>
          <div ui-card-header class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
            <div class="space-y-1">
              <div class="flex items-center gap-2">
                <span class="text-muted-foreground font-mono text-sm font-medium">{{ transferId }}</span>
                <span ui-badge [variant]="statusVariant()" class="capitalize">{{ statusLabel() }}</span>
              </div>
              <h3 ui-card-title class="text-xl">Inter-Warehouse Stock Transfer</h3>
              <p ui-card-description>
                Transfer goods between fulfillment hubs with bin-level tracking and scan verification.
              </p>
            </div>
            <div class="flex items-center gap-2">
              @if (status() === 'staged') {
                <button ui-button variant="default" class="gap-2" (click)="status.set('in_transit')">
                  <lucide-icon [img]="Truck" class="size-4" />
                  Dispatch Transfer
                </button>
              }
              @if (status() === 'in_transit') {
                <button ui-button variant="default" class="gap-2" (click)="status.set('received')">
                  <lucide-icon [img]="CheckCircle2" class="size-4" />
                  Confirm Receipt
                </button>
              }
              @if (status() === 'received') {
                <button ui-button variant="outline" class="gap-2" (click)="status.set('staged')">Reset Transfer</button>
              }
            </div>
          </div>
    
          <div ui-card-content class="border-border grid grid-cols-1 gap-4 border-t pt-4 sm:grid-cols-3">
            <!-- Source Hub -->
            <div class="border-border space-y-2 rounded-lg border p-3">
              <div class="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
                <lucide-icon [img]="Warehouse" class="size-3.5" />
                <span>ORIGIN FACILITY</span>
              </div>
              <ui-select
                [value]="sourceWarehouse()"
                (valueChange)="sourceWarehouse.set($event)"
                [disabled]="routingLocked()"
              >
                <button ui-select-trigger class="w-full">
                  <ui-select-value placeholder="Select origin warehouse" />
                </button>
                <ui-select-content>
                  <ui-select-item value="wh-east-01">East Coast Fulfillment (NJ-01)</ui-select-item>
                  <ui-select-item value="wh-west-02">West Coast Distribution (CA-04)</ui-select-item>
                  <ui-select-item value="wh-midwest-03">Midwest Logistics Hub (IL-02)</ui-select-item>
                </ui-select-content>
              </ui-select>
            </div>
    
            <!-- Destination Hub -->
            <div class="border-border space-y-2 rounded-lg border p-3">
              <div class="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
                <lucide-icon [img]="ArrowRight" class="size-3.5" />
                <span>DESTINATION FACILITY</span>
              </div>
              <ui-select
                [value]="targetWarehouse()"
                (valueChange)="targetWarehouse.set($event)"
                [disabled]="routingLocked()"
              >
                <button ui-select-trigger class="w-full">
                  <ui-select-value placeholder="Select target warehouse" />
                </button>
                <ui-select-content>
                  <ui-select-item value="wh-central-02">Central Distribution Center (TX-01)</ui-select-item>
                  <ui-select-item value="wh-north-01">Northern Sorting Facility (WA-02)</ui-select-item>
                  <ui-select-item value="wh-south-03">Southeast Regional Hub (GA-03)</ui-select-item>
                </ui-select-content>
              </ui-select>
            </div>
    
            <!-- Carrier / Transport -->
            <div class="border-border space-y-2 rounded-lg border p-3">
              <div class="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
                <lucide-icon [img]="Truck" class="size-3.5" />
                <span>SHIPPING METHOD</span>
              </div>
              <ui-select [value]="transportType()" (valueChange)="transportType.set($event)" [disabled]="routingLocked()">
                <button ui-select-trigger class="w-full">
                  <ui-select-value placeholder="Select transit mode" />
                </button>
                <ui-select-content>
                  <ui-select-item value="pallet_freight">Dedicated LTL Freight (Palletized)</ui-select-item>
                  <ui-select-item value="express_courier">Priority Courier (Same Day Air)</ui-select-item>
                  <ui-select-item value="internal_fleet">Internal Fleet Transfer (Route 09)</ui-select-item>
                </ui-select-content>
              </ui-select>
            </div>
          </div>
        </div>
    
        <!-- Line Items Table Card -->
        <div ui-card>
          <div ui-card-header class="flex flex-col gap-3 pb-3 sm:flex-row sm:items-center sm:justify-between">
            <div>
              <h3 ui-card-title class="text-base font-semibold">Manifest Line Items</h3>
              <p ui-card-description>
                {{ totalLineItems() }} unique SKUs · {{ totalUnits() }} total units staged for transfer
              </p>
            </div>
    
            <!-- Quick Scan / SKU lookup bar -->
            <div class="flex items-center gap-2">
              <div class="relative w-64">
                <lucide-icon
                  [img]="Barcode"
                  class="text-muted-foreground pointer-events-none absolute top-2.5 left-2.5 size-4"
                />
                <ui-input
                  [value]="barcodeQuery()"
                  (valueChange)="barcodeQuery.set($event)"
                  placeholder="Scan barcode or SKU..."
                  class="pl-8"
                  (keydown.enter)="handleScan()"
                />
              </div>
              <button
                ui-button
                variant="secondary"
                size="icon"
                title="Scan Barcode"
                aria-label="Scan barcode"
                (click)="handleScan()"
              >
                <lucide-icon [img]="QrCode" class="size-4" />
              </button>
              <button ui-button variant="outline" size="sm" class="gap-1" (click)="addItem()">
                <lucide-icon [img]="Plus" class="size-3.5" />
                Add Item
              </button>
            </div>
          </div>
    
          <div ui-card-content class="p-0">
            <div ui-table>
              <thead ui-table-header>
                <tr ui-table-row>
                  <th ui-table-head class="w-[280px]">Product / SKU</th>
                  <th ui-table-head>Lot / Batch</th>
                  <th ui-table-head>Source Bin</th>
                  <th ui-table-head>Target Bin</th>
                  <th ui-table-head class="text-right">Available</th>
                  <th ui-table-head class="w-[120px] text-right">Transfer Qty</th>
                  <th ui-table-head>Condition</th>
                  <th ui-table-head class="w-[50px]"></th>
                </tr>
              </thead>
              <tbody ui-table-body>
                @for (item of items(); track item.id) {
                  <tr ui-table-row>
                    <!-- SKU + Name -->
                    <td ui-table-cell>
                      <div class="font-medium">{{ item.name }}</div>
                      <div class="text-muted-foreground flex items-center gap-2 font-mono text-xs">
                        <span>{{ item.sku }}</span>
                        <span>·</span>
                        <span>{{ item.barcode }}</span>
                      </div>
                    </td>
    
                    <!-- Lot Number -->
                    <td ui-table-cell>
                      <span class="font-mono text-xs">{{ item.lotNumber }}</span>
                    </td>
    
                    <!-- Source Bin -->
                    <td ui-table-cell>
                      <span class="bg-muted text-foreground rounded px-1.5 py-0.5 font-mono text-xs font-medium">{{
                        item.sourceBin
                      }}</span>
                    </td>
    
                    <!-- Target Bin -->
                    <td ui-table-cell>
                      <span
                        class="border-border text-foreground rounded border px-1.5 py-0.5 font-mono text-xs font-medium"
                        >{{ item.targetBin }}</span
                      >
                    </td>
    
                    <!-- Available Qty -->
                    <td ui-table-cell class="text-muted-foreground text-right font-mono text-xs">
                      {{ item.availableQty }} {{ item.unit }}
                    </td>
    
                    <!-- Transfer Qty Input -->
                    <td ui-table-cell class="text-right">
                      <ui-input
                        type="number"
                        [min]="1"
                        [max]="item.availableQty"
                        [value]="item.transferQty"
                        (valueChange)="updateItemQty(item.id, $event)"
                        class="h-8 text-right font-mono"
                        [disabled]="status() === 'received'"
                      />
                    </td>
    
                    <!-- Condition -->
                    <td ui-table-cell>
                      <ui-select
                        [value]="item.condition"
                        (valueChange)="updateItemCondition(item.id, $event)"
                        [disabled]="status() === 'received'"
                      >
                        <button ui-select-trigger class="h-8 w-[110px] text-xs capitalize">
                          <ui-select-value />
                        </button>
                        <ui-select-content>
                          <ui-select-item value="sellable">Sellable</ui-select-item>
                          <ui-select-item value="quarantine">Quarantine</ui-select-item>
                          <ui-select-item value="damaged">Damaged</ui-select-item>
                        </ui-select-content>
                      </ui-select>
                    </td>
    
                    <!-- Remove Action -->
                    <td ui-table-cell>
                      <button
                        ui-button
                        variant="ghost"
                        size="icon"
                        class="text-muted-foreground hover:text-destructive size-8"
                        [disabled]="status() === 'received'"
                        [attr.aria-label]="'Remove ' + item.sku"
                        (click)="removeItem(item.id)"
                      >
                        <lucide-icon [img]="Trash2" class="size-4" />
                      </button>
                    </td>
                  </tr>
                }
              </tbody>
            </div>
          </div>
    
          <div
            ui-card-footer
            class="border-border text-muted-foreground flex items-center justify-between border-t py-3 text-xs"
          >
            <div>All inventory movements are recorded in immutable ledger log #LOG-9402.</div>
            <div class="text-foreground font-medium">
              Total: <span class="font-mono">{{ totalUnits() }}</span> items across
              <span class="font-mono">{{ totalLineItems() }}</span> positions
            </div>
          </div>
        </div>
      `,
    })
    export class UiStockTransferManagerComponent implements OnInit {
      @Input() transferId = 'TRF-2026-8841'
      @Input() initialStatus: StockTransferStatus = 'staged'
      @Input('class') className?: string
    
      protected readonly ArrowRight = ArrowRight
      protected readonly Barcode = Barcode
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Plus = Plus
      protected readonly QrCode = QrCode
      protected readonly Trash2 = Trash2
      protected readonly Truck = Truck
      protected readonly Warehouse = Warehouse
    
      readonly status = signal<StockTransferStatus>('staged')
      readonly sourceWarehouse = signal('wh-east-01')
      readonly targetWarehouse = signal('wh-central-02')
      readonly transportType = signal('pallet_freight')
      readonly barcodeQuery = signal('')
    
      readonly items = signal<TransferItem[]>([
        {
          id: 'itm-1',
          sku: 'SKU-LOGI-884',
          name: 'Industrial Barcode Scanner IP65',
          barcode: '079357318921',
          lotNumber: 'LOT-2026-A1',
          sourceBin: 'A-04-R2-B12',
          targetBin: 'C-01-R1-B03',
          availableQty: 140,
          transferQty: 25,
          unit: 'pcs',
          condition: 'sellable',
        },
        {
          id: 'itm-2',
          sku: 'SKU-PWR-331',
          name: 'Lithium Iron Battery Pack 48V',
          barcode: '079357318945',
          lotNumber: 'LOT-2025-X9',
          sourceBin: 'B-12-R4-B01',
          targetBin: 'D-08-R2-B09',
          availableQty: 48,
          transferQty: 12,
          unit: 'units',
          condition: 'sellable',
        },
        {
          id: 'itm-3',
          sku: 'SKU-SENS-102',
          name: 'Optical Proximity Sensor M18',
          barcode: '079357318988',
          lotNumber: 'LOT-2026-C4',
          sourceBin: 'A-02-R1-B06',
          targetBin: 'A-09-R3-B02',
          availableQty: 320,
          transferQty: 80,
          unit: 'pcs',
          condition: 'sellable',
        },
      ])
    
      readonly totalUnits = computed(() => this.items().reduce((sum, item) => sum + Number(item.transferQty || 0), 0))
      readonly totalLineItems = computed(() => this.items().length)
    
      /** Origin, destination and shipping method can only change before dispatch. */
      readonly routingLocked = computed(() => this.status() !== 'draft' && this.status() !== 'staged')
    
      /** React seeds its status state from `initialStatus` once, on mount. */
      ngOnInit(): void {
        this.status.set(this.initialStatus)
      }
    
      get hostClass(): string {
        return cn('block w-full space-y-6', this.className)
      }
    
      protected statusVariant(): BadgeVariant {
        const status = this.status()
        return status === 'received' ? 'default' : status === 'in_transit' ? 'secondary' : 'outline'
      }
    
      protected statusLabel(): string {
        return this.status().replace('_', ' ')
      }
    
      addItem(): void {
        this.items.update((prev) => [
          ...prev,
          {
            id: `itm-${Date.now()}`,
            sku: 'SKU-GEN-001',
            name: 'Standard Packing Material Kit',
            barcode: '079357399999',
            lotNumber: 'LOT-UNASSIGNED',
            sourceBin: 'A-01-R1-B01',
            targetBin: 'B-01-R1-B01',
            availableQty: 100,
            transferQty: 10,
            unit: 'kits',
            condition: 'sellable',
          },
        ])
      }
    
      removeItem(id: string): void {
        this.items.update((prev) => prev.filter((i) => i.id !== id))
      }
    
      updateItemQty(id: string, qty: string | number): void {
        const transferQty = Number(qty)
        this.items.update((prev) => prev.map((i) => (i.id === id ? { ...i, transferQty } : i)))
      }
    
      updateItemCondition(id: string, condition: string): void {
        this.items.update((prev) =>
          prev.map((i) => (i.id === id ? { ...i, condition: condition as TransferItemCondition } : i)),
        )
      }
    
      handleScan(): void {
        const query = this.barcodeQuery()
        if (!query) return
        this.items.update((prev) =>
          prev.map((i) => (i.barcode === query || i.sku === query ? { ...i, transferQty: i.transferQty + 1 } : i)),
        )
        this.barcodeQuery.set('')
      }
    }
    
  • src/app/components/blocks/stock-transfer-manager/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/stock-transfer-manager.json