Menu

Change language

GitHub repo

Purchase Order Receiving

blockdashboard

Inbound dock receiving workbench with PO matching, QA discrepancy logging, lot registration, and GRN generation.

Installation

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

Variants

Loading interactive previews…

Schema

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

POLineItem
interface POLineItem {
  id: string
  sku: string
  title: string
  orderedQty: number
  receivedQty: number
  rejectedQty: number
  rejectionReason: 'none' | 'damaged_packaging' | 'expired_lot' | 'wrong_specification' | 'temperature_excursion'
  lotNumber: string
  expiryDate: string
  stagingLocation: string
}

Files installed (2)

  • src/app/components/blocks/purchase-order-receiving/purchase-order-receiving.component.ts14.9 kB
    import { Component, Input, computed, signal, ChangeDetectionStrategy } from '@angular/core'
    import {
      AlertTriangle,
      Clock,
      LucideAngularModule,
      PackageCheck,
      Printer,
      ShieldCheck,
      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 interface POLineItem {
      id: string
      sku: string
      title: string
      orderedQty: number
      receivedQty: number
      rejectedQty: number
      rejectionReason: 'none' | 'damaged_packaging' | 'expired_lot' | 'wrong_specification' | 'temperature_excursion'
      lotNumber: string
      expiryDate: string
      stagingLocation: string
    }
    
    export interface PurchaseOrderReceivingProps {
      poNumber?: string
      vendorName?: string
      deliveryDocket?: string
      className?: string
    }
    
    export type InspectionStatus = 'pending' | 'passed' | 'discrepancy'
    
    /**
     * Angular port of the React PurchaseOrderReceiving block: an inbound dock workbench with the
     * PO header (docket, receipt status, print / Generate GRN actions), a receiving bay picker,
     * the accepted-vs-ordered summary and QA disposition, and a line table where each PO line's
     * received / rejected quantities, rejection reason and lot number are logged. Generating the
     * GRN locks the lines and marks the receipt passed or discrepancy. Edit the sample lines below.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-purchase-order-receiving, [ui-purchase-order-receiving]',
      standalone: true,
      host: {
        '[attr.data-slot]': '"purchase-order-receiving"',
        '[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: `
        <!-- Top PO Header Info -->
        <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-foreground font-mono text-sm font-semibold">{{ poNumber }}</span>
                <span ui-badge variant="outline" class="font-mono text-xs">{{ deliveryDocket }}</span>
                <span ui-badge [variant]="statusVariant()" class="capitalize">{{
                  isCompleted() ? 'GRN ' + inspectionStatus() : 'Receiving in Progress'
                }}</span>
              </div>
              <h3 ui-card-title class="text-xl">Inbound Goods Receipt &amp; QA Inspection</h3>
              <p ui-card-description>
                Supplier: <strong class="text-foreground font-medium">{{ vendorName }}</strong> · Inbound Dock Verification
              </p>
            </div>
    
            <div class="flex items-center gap-2">
              <button ui-button variant="outline" size="sm" class="gap-1.5">
                <lucide-icon [img]="Printer" class="size-4" />
                Print Putaway Labels
              </button>
              @if (!isCompleted()) {
                <button ui-button variant="default" size="sm" class="gap-1.5" (click)="finalizeReceipt()">
                  <lucide-icon [img]="PackageCheck" class="size-4" />
                  Generate GRN
                </button>
              } @else {
                <button ui-button variant="secondary" size="sm" class="gap-1.5" (click)="resetReceipt()">
                  Edit Receipt
                </button>
              }
            </div>
          </div>
    
          <div ui-card-content class="border-border grid grid-cols-1 gap-4 border-t pt-4 sm:grid-cols-3">
            <!-- Receiving Dock -->
            <div class="border-border space-y-1.5 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>RECEIVING BAY</span>
              </div>
              <ui-select [value]="receivingDock()" (valueChange)="receivingDock.set($event)" [disabled]="isCompleted()">
                <button ui-select-trigger class="w-full">
                  <ui-select-value />
                </button>
                <ui-select-content>
                  <ui-select-item value="dock-01">Inbound Dock Bay 01 (Heavy Freight)</ui-select-item>
                  <ui-select-item value="dock-02">Inbound Dock Bay 02 (Cross-Dock)</ui-select-item>
                  <ui-select-item value="dock-03">Inbound Dock Bay 03 (Standard Freight)</ui-select-item>
                  <ui-select-item value="dock-04">Inbound Cold Storage Quarantine Bay</ui-select-item>
                </ui-select-content>
              </ui-select>
            </div>
    
            <!-- Discrepancy Summary -->
            <div class="border-border space-y-1 rounded-lg border p-3">
              <div class="text-muted-foreground text-xs font-medium">RECEIPT SUMMARY</div>
              <div class="flex items-baseline justify-between pt-1">
                <div class="font-mono text-xl font-semibold">{{ totalAccepted() }} / {{ totalOrdered() }}</div>
                @if (totalRejected() > 0) {
                  <span ui-badge variant="destructive" class="font-mono text-xs">{{ totalRejected() }} Rejected</span>
                } @else {
                  <span ui-badge variant="outline" class="text-muted-foreground font-mono text-xs">100% Match</span>
                }
              </div>
            </div>
    
            <!-- QA Verification Badge -->
            <div class="border-border space-y-1 rounded-lg border p-3">
              <div class="text-muted-foreground text-xs font-medium">QA AUDIT DISPOSITION</div>
              <div class="flex items-center gap-2 pt-1">
                @if (inspectionStatus() === 'passed') {
                  <lucide-icon [img]="ShieldCheck" class="text-foreground size-5" />
                }
                @if (inspectionStatus() === 'discrepancy') {
                  <lucide-icon [img]="AlertTriangle" class="text-destructive size-5" />
                }
                @if (inspectionStatus() === 'pending') {
                  <lucide-icon [img]="Clock" class="text-muted-foreground size-5" />
                }
                <span class="text-sm font-medium">{{ dispositionLabel() }}</span>
              </div>
            </div>
          </div>
        </div>
    
        <!-- PO Line Item Matching Table -->
        <div ui-card>
          <div ui-card-header class="pb-3">
            <h3 ui-card-title class="text-base font-semibold">Purchase Order Line Verification</h3>
            <p ui-card-description>
              Verify quantities received against vendor packing list and assign warehouse staging zones.
            </p>
          </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]">Item / Description</th>
                  <th ui-table-head class="text-right">Ordered</th>
                  <th ui-table-head class="w-[110px] text-right">Received</th>
                  <th ui-table-head class="w-[110px] text-right">Rejected</th>
                  <th ui-table-head>Rejection Reason</th>
                  <th ui-table-head>Lot Number</th>
                  <th ui-table-head>Staging Target</th>
                </tr>
              </thead>
              <tbody ui-table-body>
                @for (line of lines(); track line.id) {
                  <tr ui-table-row>
                    <!-- Item Details -->
                    <td ui-table-cell>
                      <div class="font-medium">{{ line.title }}</div>
                      <div class="text-muted-foreground font-mono text-xs">{{ line.sku }}</div>
                    </td>
    
                    <!-- Ordered Qty -->
                    <td ui-table-cell class="text-muted-foreground text-right font-mono text-sm">
                      {{ line.orderedQty }}
                    </td>
    
                    <!-- Received Qty -->
                    <td ui-table-cell class="text-right">
                      <ui-input
                        type="number"
                        [min]="0"
                        [value]="line.receivedQty"
                        (valueChange)="updateLine(line.id, { receivedQty: toNumber($event) })"
                        class="h-8 text-right font-mono"
                        [disabled]="isCompleted()"
                      />
                    </td>
    
                    <!-- Rejected Qty -->
                    <td ui-table-cell class="text-right">
                      <ui-input
                        type="number"
                        [min]="0"
                        [value]="line.rejectedQty"
                        (valueChange)="updateLine(line.id, { rejectedQty: toNumber($event) })"
                        class="h-8 text-right font-mono"
                        [disabled]="isCompleted()"
                      />
                    </td>
    
                    <!-- Reason -->
                    <td ui-table-cell>
                      <ui-select
                        [value]="line.rejectionReason"
                        (valueChange)="setRejectionReason(line.id, $event)"
                        [disabled]="isCompleted() || line.rejectedQty === 0"
                      >
                        <button ui-select-trigger class="h-8 w-[160px] text-xs">
                          <ui-select-value />
                        </button>
                        <ui-select-content>
                          <ui-select-item value="none">None (Accepted)</ui-select-item>
                          <ui-select-item value="damaged_packaging">Damaged Packaging</ui-select-item>
                          <ui-select-item value="expired_lot">Short Shelf Life</ui-select-item>
                          <ui-select-item value="wrong_specification">Incorrect Spec</ui-select-item>
                          <ui-select-item value="temperature_excursion">Temp Excursion</ui-select-item>
                        </ui-select-content>
                      </ui-select>
                    </td>
    
                    <!-- Lot Number Input -->
                    <td ui-table-cell>
                      <ui-input
                        [value]="line.lotNumber"
                        (valueChange)="updateLine(line.id, { lotNumber: $event })"
                        placeholder="Lot #"
                        class="h-8 w-28 font-mono text-xs"
                        [disabled]="isCompleted()"
                      />
                    </td>
    
                    <!-- Staging Target -->
                    <td ui-table-cell>
                      <span class="bg-muted text-foreground rounded px-2 py-1 font-mono text-xs font-medium">{{
                        line.stagingLocation
                      }}</span>
                    </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>Official GRN generates supplier debit note automatically for any non-zero rejection.</div>
            <div class="text-foreground font-medium">
              Accepted: <span class="font-mono font-semibold">{{ totalAccepted() }}</span> units · Rejected:
              <span class="text-destructive font-mono font-semibold">{{ totalRejected() }}</span> units
            </div>
          </div>
        </div>
      `,
    })
    export class UiPurchaseOrderReceivingComponent {
      @Input() poNumber = 'PO-2026-4409'
      @Input() vendorName = 'Apex Precision Engineering Ltd'
      @Input() deliveryDocket = 'BOL-APX-98214'
      @Input('class') className?: string
    
      protected readonly AlertTriangle = AlertTriangle
      protected readonly Clock = Clock
      protected readonly PackageCheck = PackageCheck
      protected readonly Printer = Printer
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Warehouse = Warehouse
    
      readonly isCompleted = signal(false)
      readonly receivingDock = signal('dock-03')
      readonly inspectionStatus = signal<InspectionStatus>('pending')
    
      readonly lines = signal<POLineItem[]>([
        {
          id: 'po-1',
          sku: 'SKU-VALVE-99',
          title: 'High-Pressure Hydraulic Valve 3/8"',
          orderedQty: 100,
          receivedQty: 100,
          rejectedQty: 0,
          rejectionReason: 'none',
          lotNumber: 'LOT-HYD-998',
          expiryDate: '2029-12-31',
          stagingLocation: 'ZONE-A-RACK-04',
        },
        {
          id: 'po-2',
          sku: 'SKU-SEAL-04',
          title: 'Fluorocarbon O-Ring Flange Kit (Pack of 50)',
          orderedQty: 250,
          receivedQty: 245,
          rejectedQty: 5,
          rejectionReason: 'damaged_packaging',
          lotNumber: 'LOT-O-7712',
          expiryDate: '2028-06-30',
          stagingLocation: 'ZONE-A-RACK-01',
        },
        {
          id: 'po-3',
          sku: 'SKU-GAUGE-12',
          title: 'Digital Calibration Pressure Gauge 0-600 PSI',
          orderedQty: 40,
          receivedQty: 40,
          rejectedQty: 0,
          rejectionReason: 'none',
          lotNumber: 'LOT-CAL-002',
          expiryDate: '2031-01-15',
          stagingLocation: 'ZONE-B-RACK-09',
        },
      ])
    
      readonly totalOrdered = computed(() => this.lines().reduce((acc, item) => acc + item.orderedQty, 0))
      readonly totalAccepted = computed(() => this.lines().reduce((acc, item) => acc + item.receivedQty, 0))
      readonly totalRejected = computed(() => this.lines().reduce((acc, item) => acc + item.rejectedQty, 0))
    
      readonly statusVariant = computed<BadgeVariant>(() => {
        const status = this.inspectionStatus()
        return status === 'passed' ? 'default' : status === 'discrepancy' ? 'destructive' : 'secondary'
      })
    
      readonly dispositionLabel = computed(() => {
        const status = this.inspectionStatus()
        return status === 'passed'
          ? 'All lines cleared for putaway'
          : status === 'discrepancy'
            ? 'Discrepancy logged for vendor credit'
            : 'Awaiting line verification'
      })
    
      get hostClass(): string {
        return cn('block w-full space-y-6', this.className)
      }
    
      updateLine(id: string, updates: Partial<POLineItem>): void {
        this.lines.update((prev) => prev.map((line) => (line.id === id ? { ...line, ...updates } : line)))
      }
    
      setRejectionReason(id: string, value: string): void {
        this.updateLine(id, { rejectionReason: value as POLineItem['rejectionReason'] })
      }
    
      protected toNumber(value: string): number {
        return Number(value)
      }
    
      finalizeReceipt(): void {
        if (this.totalRejected() > 0 || this.totalAccepted() < this.totalOrdered()) {
          this.inspectionStatus.set('discrepancy')
        } else {
          this.inspectionStatus.set('passed')
        }
        this.isCompleted.set(true)
      }
    
      resetReceipt(): void {
        this.isCompleted.set(false)
        this.inspectionStatus.set('pending')
      }
    }
    
  • src/app/components/blocks/purchase-order-receiving/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/purchase-order-receiving.json