Menu

Change language

GitHub repo

Order Management Page

blockcommerce

E-commerce and SaaS order fulfillment admin table: header with export CSV and new order action, 4 KPI summary metric cards, search and multi-status filter toolbar with date range selector, selectable order rows with customer avatars and status badges, slide-over order details drawer with line items breakdown and tracking info, bulk action bar, and pagination.

Installation

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

Variants

Loading interactive previews…

Schema

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

OrderItem
interface OrderItem {
  name: string
  sku: string
  quantity: number
  price: number
}
CustomerInfo
interface CustomerInfo {
  name: string
  email: string
  avatar?: string
  address: string
  city: string
  state: string
  zip: string
}
Order
interface Order {
  id: string
  orderNumber: string
  date: string
  customer: CustomerInfo
  items: OrderItem[]
  status: OrderStatus
  paymentMethod: string
  subtotal: number
  shipping: number
  tax: number
  total: number
  carrier?: string
  trackingNumber?: string
}

Files installed (2)

  • src/app/components/blocks/order-management-page/order-management-page.component.ts35.3 kB
    // E-commerce and SaaS order fulfillment admin table: header actions, 4 KPI cards,
    // search + status filter toolbar, selectable rows with avatars and status badges,
    // slide-over order details drawer, bulk action bar, and pagination. Port of the
    // Vue/React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      ArrowUpRight,
      Calendar,
      ChevronDown,
      Clock,
      DollarSign,
      Download,
      Eye,
      FileText,
      LucideAngularModule,
      MoreHorizontal,
      PackageCheck,
      Plus,
      RotateCcw,
      Search,
      ShoppingCart,
      TrendingUp,
      Truck,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiAvatarComponent, UiAvatarFallbackComponent } from '@/ui/avatar/avatar.component'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiCardComponent, UiCardContentComponent } from '@/ui/card/card.component'
    import { UiCheckboxComponent } from '@/ui/checkbox/checkbox.component'
    import {
      UiDropdownMenuComponent,
      UiDropdownMenuContentComponent,
      UiDropdownMenuItemComponent,
      UiDropdownMenuLabelComponent,
      UiDropdownMenuSeparatorComponent,
      UiDropdownMenuTriggerComponent,
    } from '@/ui/dropdown-menu/dropdown-menu.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import {
      UiSheetCloseComponent,
      UiSheetComponent,
      UiSheetContentComponent,
      UiSheetDescriptionComponent,
      UiSheetFooterComponent,
      UiSheetHeaderComponent,
      UiSheetTitleComponent,
    } from '@/ui/sheet/sheet.component'
    import {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    
    export type OrderStatus = 'paid' | 'processing' | 'shipped' | 'refunded'
    
    export interface OrderItem {
      name: string
      sku: string
      quantity: number
      price: number
    }
    
    export interface CustomerInfo {
      name: string
      email: string
      avatar?: string
      address: string
      city: string
      state: string
      zip: string
    }
    
    export interface Order {
      id: string
      orderNumber: string
      date: string
      customer: CustomerInfo
      items: OrderItem[]
      status: OrderStatus
      paymentMethod: string
      subtotal: number
      shipping: number
      tax: number
      total: number
      carrier?: string
      trackingNumber?: string
    }
    
    export const orders: Order[] = [
      {
        id: 'ord-1',
        orderNumber: '#ORD-8492',
        date: 'Oct 24, 2026',
        customer: {
          name: 'Sarah Chen',
          email: '[email protected]',
          address: '742 Evergreen Terrace',
          city: 'Springfield',
          state: 'OR',
          zip: '97477',
        },
        items: [
          { name: 'Ergonomic Mechanical Keyboard', sku: 'KB-ERGO-01', quantity: 1, price: 189 },
          { name: 'Desk Mat Pro (Midnight)', sku: 'MAT-PRO-BLK', quantity: 1, price: 39 },
        ],
        status: 'processing',
        paymentMethod: 'Mastercard •••• 8492',
        subtotal: 228,
        shipping: 0,
        tax: 18.24,
        total: 246.24,
        carrier: 'FedEx Priority',
        trackingNumber: 'FX-9281034-US',
      },
      {
        id: 'ord-2',
        orderNumber: '#ORD-8491',
        date: 'Oct 24, 2026',
        customer: {
          name: 'Marcus Vance',
          email: '[email protected]',
          address: '100 Enterprise Way, Suite 400',
          city: 'San Francisco',
          state: 'CA',
          zip: '94105',
        },
        items: [
          { name: 'UltraWide Monitor 34"', sku: 'MON-34-UW', quantity: 1, price: 699 },
          { name: 'Dual Monitor Arm Mount', sku: 'ACC-ARM-02', quantity: 1, price: 129 },
          { name: 'Braided DisplayPort Cable 2m', sku: 'CAB-DP-02', quantity: 1, price: 24 },
        ],
        status: 'shipped',
        paymentMethod: 'Visa •••• 4242',
        subtotal: 852,
        shipping: 0,
        tax: 68.16,
        total: 920.16,
        carrier: 'UPS Ground',
        trackingNumber: '1Z999AA10123456784',
      },
      {
        id: 'ord-3',
        orderNumber: '#ORD-8490',
        date: 'Oct 23, 2026',
        customer: {
          name: 'Elena Rostova',
          email: '[email protected]',
          address: '450 Innovation Parkway',
          city: 'Austin',
          state: 'TX',
          zip: '78701',
        },
        items: [{ name: 'SaaS Enterprise Annual License', sku: 'LIC-ENT-YR', quantity: 1, price: 1200 }],
        status: 'paid',
        paymentMethod: 'Visa •••• 9104',
        subtotal: 1200,
        shipping: 0,
        tax: 0,
        total: 1200,
      },
      {
        id: 'ord-4',
        orderNumber: '#ORD-8489',
        date: 'Oct 23, 2026',
        customer: {
          name: 'David Kim',
          email: '[email protected]',
          address: '88 King Street West',
          city: 'Seattle',
          state: 'WA',
          zip: '98104',
        },
        items: [
          { name: 'USB-C Multiport Dock Pro', sku: 'HUB-USBC-8P', quantity: 2, price: 79 },
          { name: 'Thunderbolt 4 Braided Cable', sku: 'CAB-TB4-1M', quantity: 2, price: 29 },
        ],
        status: 'shipped',
        paymentMethod: 'Apple Pay',
        subtotal: 216,
        shipping: 0,
        tax: 17.28,
        total: 233.28,
        carrier: 'DHL Express',
        trackingNumber: 'DHL-8492019-EXP',
      },
      {
        id: 'ord-5',
        orderNumber: '#ORD-8488',
        date: 'Oct 22, 2026',
        customer: {
          name: 'Olivia Taylor',
          email: '[email protected]',
          address: '12 Marina Boulevard',
          city: 'Boston',
          state: 'MA',
          zip: '02210',
        },
        items: [{ name: 'Noise-Cancelling Studio Headphones', sku: 'AUD-NC-700', quantity: 1, price: 349 }],
        status: 'refunded',
        paymentMethod: 'Visa •••• 1128',
        subtotal: 349,
        shipping: 0,
        tax: 27.92,
        total: 376.92,
      },
      {
        id: 'ord-6',
        orderNumber: '#ORD-8487',
        date: 'Oct 22, 2026',
        customer: {
          name: 'Liam Johansson',
          email: '[email protected]',
          address: '240 Queen Street',
          city: 'New York',
          state: 'NY',
          zip: '10001',
        },
        items: [
          { name: 'Aluminum Laptop Riser', sku: 'STN-ALUM-SLV', quantity: 1, price: 65 },
          { name: 'Wireless Precision Mouse', sku: 'MOU-WL-01', quantity: 1, price: 99 },
        ],
        status: 'processing',
        paymentMethod: 'Mastercard •••• 5531',
        subtotal: 164,
        shipping: 0,
        tax: 13.12,
        total: 177.12,
        carrier: 'USPS Priority',
        trackingNumber: '9400100000000000000000',
      },
      {
        id: 'ord-7',
        orderNumber: '#ORD-8486',
        date: 'Oct 21, 2026',
        customer: {
          name: 'Aisha Patel',
          email: '[email protected]',
          address: '500 Central Avenue',
          city: 'Chicago',
          state: 'IL',
          zip: '60601',
        },
        items: [{ name: 'Cloud Storage Tier 3 (500GB)', sku: 'SRV-STR-500', quantity: 1, price: 450 }],
        status: 'paid',
        paymentMethod: 'Amex •••• 3009',
        subtotal: 450,
        shipping: 0,
        tax: 36,
        total: 486,
      },
      {
        id: 'ord-8',
        orderNumber: '#ORD-8485',
        date: 'Oct 20, 2026',
        customer: {
          name: 'Lucas Moreau',
          email: '[email protected]',
          address: '15 Rue de la Paix',
          city: 'Boulder',
          state: 'CO',
          zip: '80302',
        },
        items: [
          { name: 'Broadcast Studio Microphone', sku: 'MIC-POD-01', quantity: 1, price: 229 },
          { name: 'Dual-Layer Pop Filter', sku: 'ACC-POP-01', quantity: 1, price: 25 },
          { name: 'Gold-Plated XLR Cable 3m', sku: 'CAB-XLR-03', quantity: 1, price: 19 },
        ],
        status: 'shipped',
        paymentMethod: 'Visa •••• 7712',
        subtotal: 273,
        shipping: 0,
        tax: 21.84,
        total: 294.84,
        carrier: 'FedEx Ground',
        trackingNumber: 'FX-7712093-US',
      },
    ]
    
    const statusBadgeConfig: Record<OrderStatus, { label: string; class: string; dot: string }> = {
      paid: { label: 'Paid', class: 'bg-success/10 text-success border-success/20', dot: 'bg-success' },
      processing: { label: 'Processing', class: 'bg-info/10 text-info border-info/20', dot: 'bg-info' },
      shipped: { label: 'Shipped', class: 'bg-chart-1/10 text-chart-1 border-chart-1/20', dot: 'bg-chart-1' },
      refunded: {
        label: 'Refunded',
        class: 'bg-destructive/10 text-destructive border-destructive/20',
        dot: 'bg-destructive',
      },
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-order-management-page, [ui-order-management-page]',
      standalone: true,
      // The block renders its own root <div> (and the sheet portals to body): keep the host out of layout.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiAvatarComponent,
        UiAvatarFallbackComponent,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCheckboxComponent,
        UiDropdownMenuComponent,
        UiDropdownMenuContentComponent,
        UiDropdownMenuItemComponent,
        UiDropdownMenuLabelComponent,
        UiDropdownMenuSeparatorComponent,
        UiDropdownMenuTriggerComponent,
        UiInputComponent,
        UiSheetCloseComponent,
        UiSheetComponent,
        UiSheetContentComponent,
        UiSheetDescriptionComponent,
        UiSheetFooterComponent,
        UiSheetHeaderComponent,
        UiSheetTitleComponent,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
      ],
      template: `
        <div data-slot="order-management-page" [class]="rootClass">
          <!-- Page Header -->
          <div class="flex flex-wrap items-center justify-between gap-4">
            <div>
              <h2 class="text-foreground text-2xl font-bold tracking-tight">Orders</h2>
              <p class="text-muted-foreground mt-1 text-sm">Manage and track customer purchases</p>
            </div>
            <div class="flex items-center gap-2">
              <button ui-button aria-label="Download attachment" variant="outline" size="sm" class="text-xs">
                <lucide-icon [img]="Download" class="mr-1.5 size-3.5" aria-hidden="true" />
                Export CSV
              </button>
              <button ui-button size="sm" class="text-xs">
                <lucide-icon [img]="Plus" class="mr-1.5 size-3.5" aria-hidden="true" />
                New Order
              </button>
            </div>
          </div>
    
          <!-- 4 Stat/KPI Summary Cards -->
          <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
            <div ui-card class="shadow-xs">
              <div ui-card-content class="p-4 sm:p-5">
                <div class="flex items-center justify-between">
                  <p class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Total Orders</p>
                  <div class="bg-muted text-muted-foreground flex size-8 items-center justify-center rounded-md">
                    <lucide-icon [img]="ShoppingCart" class="size-4" aria-hidden="true" />
                  </div>
                </div>
                <div class="mt-3 flex flex-wrap items-baseline justify-between gap-2">
                  <span class="text-foreground text-2xl font-bold tracking-tight tabular-nums">1,429</span>
                  <span class="text-success inline-flex items-center gap-0.5 text-xs font-medium">
                    <lucide-icon [img]="TrendingUp" class="size-3.5" aria-hidden="true" />
                    +8.2%
                  </span>
                </div>
                <p class="text-muted-foreground mt-1 text-xs">vs. previous 30 days</p>
              </div>
            </div>
    
            <div ui-card class="shadow-xs">
              <div ui-card-content class="p-4 sm:p-5">
                <div class="flex items-center justify-between">
                  <p class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Pending Fulfillment</p>
                  <div class="bg-warning/10 text-warning flex size-8 items-center justify-center rounded-md">
                    <lucide-icon [img]="Clock" class="size-4" aria-hidden="true" />
                  </div>
                </div>
                <div class="mt-3 flex flex-wrap items-baseline justify-between gap-2">
                  <span class="text-foreground text-2xl font-bold tracking-tight tabular-nums">28</span>
                  <span
                    class="bg-warning/10 text-warning inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
                  >
                    12 urgent
                  </span>
                </div>
                <p class="text-muted-foreground mt-1 text-xs">Requires warehouse dispatch</p>
              </div>
            </div>
    
            <div ui-card class="shadow-xs">
              <div ui-card-content class="p-4 sm:p-5">
                <div class="flex items-center justify-between">
                  <p class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Processing</p>
                  <div class="bg-info/10 text-info flex size-8 items-center justify-center rounded-md">
                    <lucide-icon [img]="PackageCheck" class="size-4" aria-hidden="true" />
                  </div>
                </div>
                <div class="mt-3 flex flex-wrap items-baseline justify-between gap-2">
                  <span class="text-foreground text-2xl font-bold tracking-tight tabular-nums">45</span>
                  <span class="bg-info/10 text-info inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium">
                    In queue
                  </span>
                </div>
                <p class="text-muted-foreground mt-1 text-xs">Packaging & label generation</p>
              </div>
            </div>
    
            <div ui-card class="shadow-xs">
              <div ui-card-content class="p-4 sm:p-5">
                <div class="flex items-center justify-between">
                  <p class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Total Revenue</p>
                  <div class="bg-muted text-muted-foreground flex size-8 items-center justify-center rounded-md">
                    <lucide-icon [img]="DollarSign" class="size-4" aria-hidden="true" />
                  </div>
                </div>
                <div class="mt-3 flex flex-wrap items-baseline justify-between gap-2">
                  <span class="text-foreground text-2xl font-bold tracking-tight tabular-nums">$128,450</span>
                  <span class="text-success inline-flex items-center gap-0.5 text-xs font-medium">
                    <lucide-icon [img]="ArrowUpRight" class="size-3.5" aria-hidden="true" />
                    +14.5%
                  </span>
                </div>
                <p class="text-muted-foreground mt-1 text-xs">vs. previous period</p>
              </div>
            </div>
          </div>
    
          <!-- Search and Filter Toolbar -->
          <div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
            <div class="flex flex-1 flex-wrap items-center gap-2">
              <ng-template #orderSearchIcon>
                <lucide-icon [img]="Search" class="size-4" aria-hidden="true" />
              </ng-template>
              <div class="relative min-w-[240px] flex-1 sm:max-w-xs">
                <ui-input
                  [value]="search()"
                  (valueChange)="search.set($event)"
                  placeholder="Search order ID, customer..."
                  [prefixIcon]="orderSearchIcon"
                  class="h-9 pl-2 text-xs"
                />
              </div>
    
              <div class="bg-muted/60 flex items-center gap-1 rounded-lg border p-1">
                @for (status of statusOptions(); track status.value) {
                  <button type="button" [class]="statusPillClass(status.value)" (click)="setStatusFilter(status.value)">
                    {{ status.label }}
                    <span [class]="statusCountClass(status.value)">{{ status.count }}</span>
                  </button>
                }
              </div>
            </div>
    
            <div class="flex items-center gap-2">
              <button ui-button variant="outline" size="sm" class="text-xs font-normal">
                <lucide-icon [img]="Calendar" class="text-muted-foreground mr-1.5 size-3.5" aria-hidden="true" />
                Last 30 days
                <lucide-icon [img]="ChevronDown" class="text-muted-foreground ml-1.5 size-3.5" aria-hidden="true" />
              </button>
            </div>
          </div>
    
          <!-- Bulk Action Bar -->
          @if (selected().size > 0) {
            <div
              class="bg-muted/50 flex flex-wrap items-center justify-between gap-2 rounded-lg border px-4 py-2.5 shadow-xs"
            >
              <div class="flex items-center gap-2">
                <span
                  class="bg-primary text-primary-foreground flex size-5 items-center justify-center rounded-full text-xs font-medium"
                >
                  {{ selected().size }}
                </span>
                <span class="text-foreground text-sm font-medium">
                  {{ selected().size }} {{ selected().size === 1 ? 'order' : 'orders' }} selected
                </span>
              </div>
              <div class="flex items-center gap-2">
                <button
                  ui-button
                  aria-label="Download attachment"
                  variant="outline"
                  size="sm"
                  class="text-xs"
                  (click)="exportSelected()"
                >
                  <lucide-icon [img]="Download" class="mr-1.5 size-3.5" aria-hidden="true" />
                  Export selected
                </button>
                <button ui-button variant="outline" size="sm" class="text-xs" (click)="markSelectedShipped()">
                  <lucide-icon [img]="Truck" class="mr-1.5 size-3.5" aria-hidden="true" />
                  Mark as shipped
                </button>
                <button
                  ui-button
                  variant="ghost"
                  size="sm"
                  class="text-destructive hover:text-destructive text-xs"
                  (click)="clearSelection()"
                >
                  Cancel
                </button>
              </div>
            </div>
          }
    
          <!-- Orders Table -->
          <div class="bg-card overflow-x-auto rounded-lg border shadow-xs">
            <table ui-table>
              <thead ui-table-header>
                <tr ui-table-row class="hover:bg-transparent">
                  <th ui-table-head class="w-10">
                    <ui-checkbox
                      [checked]="allSelected()"
                      [indeterminate]="someSelected() && !allSelected()"
                      aria-label="Select all orders"
                      (checkedChange)="toggleAll($event === true)"
                    />
                  </th>
                  <th ui-table-head class="min-w-[110px]">Order ID</th>
                  <th ui-table-head class="min-w-[100px]">Date</th>
                  <th ui-table-head class="min-w-[200px]">Customer</th>
                  <th ui-table-head class="min-w-[220px]">Items</th>
                  <th ui-table-head class="min-w-[90px] text-right">Total</th>
                  <th ui-table-head class="min-w-[120px]">Fulfillment</th>
                  <th ui-table-head class="w-10 text-right"><span class="sr-only">Actions</span></th>
                </tr>
              </thead>
              <tbody ui-table-body>
                @for (order of filteredOrders(); track order.id) {
                  <tr
                    ui-table-row
                    class="group hover:bg-muted/40 cursor-pointer transition-colors"
                    (click)="openOrderDetails(order)"
                  >
                    <td ui-table-cell class="w-10" (click)="$event.stopPropagation()">
                      <ui-checkbox
                        [checked]="selected().has(order.id)"
                        [attr.aria-label]="'Select order ' + order.orderNumber"
                        (checkedChange)="toggleRow(order.id, $event === true)"
                      />
                    </td>
                    <td ui-table-cell>
                      <span class="text-foreground font-mono text-xs font-semibold hover:underline">
                        {{ order.orderNumber }}
                      </span>
                    </td>
                    <td ui-table-cell class="text-muted-foreground text-xs tabular-nums">{{ order.date }}</td>
                    <td ui-table-cell>
                      <div class="flex items-center gap-2.5">
                        <ui-avatar class="size-7">
                          <ui-avatar-fallback class="text-xs font-medium">{{
                            initials(order.customer.name)
                          }}</ui-avatar-fallback>
                        </ui-avatar>
                        <div class="min-w-0">
                          <p class="text-foreground truncate text-sm font-medium">{{ order.customer.name }}</p>
                          <p class="text-muted-foreground truncate text-xs">{{ order.customer.email }}</p>
                        </div>
                      </div>
                    </td>
                    <td ui-table-cell>
                      <div class="text-xs">
                        <span class="text-foreground font-medium">
                          {{ itemCount(order) }} {{ itemCount(order) === 1 ? 'item' : 'items' }}
                        </span>
                        <span class="text-muted-foreground ml-1.5 inline-block max-w-[180px] truncate align-bottom">
                          • {{ order.items[0]?.name
                          }}{{ order.items.length > 1 ? ' +' + (order.items.length - 1) + ' more' : '' }}
                        </span>
                      </div>
                    </td>
                    <td ui-table-cell class="text-foreground text-right font-mono text-xs font-semibold tabular-nums">
                      &#36;{{ formatCurrency(order.total) }}
                    </td>
                    <td ui-table-cell>
                      <span ui-badge variant="outline" [class]="statusBadgeClass(order.status)">
                        <span [class]="statusDotClass(order.status)" aria-hidden="true"></span>
                        {{ statusBadgeConfig[order.status].label }}
                      </span>
                    </td>
                    <td ui-table-cell class="text-right" (click)="$event.stopPropagation()">
                      <ui-dropdown-menu>
                        <button
                          ui-button
                          ui-dropdown-menu-trigger
                          variant="ghost"
                          size="icon-sm"
                          class="text-muted-foreground hover:text-foreground"
                        >
                          <lucide-icon [img]="MoreHorizontal" class="size-4" aria-hidden="true" />
                          <span class="sr-only">Actions for {{ order.orderNumber }}</span>
                        </button>
                        <ui-dropdown-menu-content align="end" class="w-48">
                          <ui-dropdown-menu-label>Order actions</ui-dropdown-menu-label>
                          <ui-dropdown-menu-item (select)="openOrderDetails(order)">
                            <lucide-icon [img]="Eye" class="mr-2 size-4" aria-hidden="true" />
                            View details
                          </ui-dropdown-menu-item>
                          <ui-dropdown-menu-item>
                            <lucide-icon [img]="FileText" class="mr-2 size-4" aria-hidden="true" />
                            Download invoice
                          </ui-dropdown-menu-item>
                          @if (order.status === 'processing') {
                            <ui-dropdown-menu-item>
                              <lucide-icon [img]="Truck" class="mr-2 size-4" aria-hidden="true" />
                              Mark as shipped
                            </ui-dropdown-menu-item>
                          }
                          <ui-dropdown-menu-separator />
                          @if (order.status !== 'refunded') {
                            <ui-dropdown-menu-item class="text-destructive focus:text-destructive">
                              <lucide-icon [img]="RotateCcw" class="mr-2 size-4" aria-hidden="true" />
                              Issue refund
                            </ui-dropdown-menu-item>
                          }
                        </ui-dropdown-menu-content>
                      </ui-dropdown-menu>
                    </td>
                  </tr>
                }
                @if (filteredOrders().length === 0) {
                  <tr ui-table-row>
                    <td ui-table-cell colspan="8" class="text-muted-foreground h-32 text-center text-sm">
                      No orders match your filter criteria.
                    </td>
                  </tr>
                }
              </tbody>
            </table>
          </div>
    
          <!-- Pagination Footer -->
          <div class="flex flex-wrap items-center justify-between gap-4">
            <p class="text-muted-foreground text-xs tabular-nums">
              Showing 1-{{ filteredOrders().length }} of 1,429 orders
            </p>
            <div class="flex items-center gap-2">
              <button ui-button variant="outline" size="sm" class="text-xs" disabled>Previous</button>
              <button ui-button variant="outline" size="sm" class="text-xs">Next</button>
            </div>
          </div>
    
          <!-- Quick Details Drawer / Sheet -->
          <div ui-sheet [open]="drawerOpen()" (openChange)="drawerOpen.set($event)">
            <div ui-sheet-content side="right" class="flex w-full flex-col gap-0 overflow-y-auto p-0 sm:max-w-lg">
              @if (selectedOrder(); as detail) {
                <div class="flex h-full flex-col">
                  <!-- Sheet Header -->
                  <div ui-sheet-header class="space-y-1 border-b px-6 py-5">
                    <div class="flex items-center justify-between pr-6">
                      <span class="text-foreground font-mono text-base font-bold">{{ detail.orderNumber }}</span>
                      <span ui-badge variant="outline" [class]="statusBadgeDrawerClass(detail.status)">
                        <span [class]="statusDotClass(detail.status)" aria-hidden="true"></span>
                        {{ statusBadgeConfig[detail.status].label }}
                      </span>
                    </div>
                    <h2 ui-sheet-title class="text-muted-foreground text-xs font-normal">Placed on {{ detail.date }}</h2>
                    <p ui-sheet-description class="sr-only">Detailed view of order {{ detail.orderNumber }}</p>
                  </div>
    
                  <!-- Sheet Body -->
                  <div class="flex-1 space-y-6 overflow-y-auto px-6 py-5 text-sm">
                    <!-- Customer & Shipping -->
                    <div class="bg-muted/20 space-y-3 rounded-lg border p-4">
                      <div class="flex items-center justify-between">
                        <h4 class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
                          Customer & Shipping
                        </h4>
                        <span class="text-muted-foreground font-mono text-xs">{{ detail.paymentMethod }}</span>
                      </div>
                      <div class="flex items-center gap-3">
                        <ui-avatar class="size-9">
                          <ui-avatar-fallback class="text-xs font-semibold">
                            {{ initials(detail.customer.name) }}
                          </ui-avatar-fallback>
                        </ui-avatar>
                        <div class="min-w-0">
                          <p class="text-foreground font-medium">{{ detail.customer.name }}</p>
                          <p class="text-muted-foreground text-xs">{{ detail.customer.email }}</p>
                        </div>
                      </div>
                      <div class="text-muted-foreground border-t pt-2.5 text-xs">
                        <p class="text-foreground font-medium">Delivery address:</p>
                        <p>{{ detail.customer.address }}</p>
                        <p>{{ detail.customer.city }}, {{ detail.customer.state }} {{ detail.customer.zip }}</p>
                      </div>
                    </div>
    
                    <!-- Fulfillment & Tracking if available -->
                    @if (detail.carrier && detail.trackingNumber) {
                      <div class="border-border bg-card space-y-2 rounded-lg border p-4">
                        <div class="flex items-center justify-between">
                          <span class="text-muted-foreground text-xs font-semibold tracking-wider uppercase"
                            >Fulfillment</span
                          >
                          <span ui-badge variant="secondary" class="text-xs font-normal">In Transit</span>
                        </div>
                        <div class="flex items-center justify-between pt-1 text-xs">
                          <span class="text-muted-foreground">Carrier:</span>
                          <span class="text-foreground font-medium">{{ detail.carrier }}</span>
                        </div>
                        <div class="flex items-center justify-between text-xs">
                          <span class="text-muted-foreground">Tracking ID:</span>
                          <span class="text-foreground font-mono font-medium">{{ detail.trackingNumber }}</span>
                        </div>
                      </div>
                    }
    
                    <!-- Line Items -->
                    <div class="space-y-3">
                      <h4 class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">Line Items</h4>
                      <div class="divide-border bg-card divide-y rounded-lg border">
                        @for (item of detail.items; track item.sku) {
                          <div class="flex items-center justify-between gap-3 p-3.5">
                            <div class="min-w-0 flex-1">
                              <p class="text-foreground truncate text-sm font-medium">{{ item.name }}</p>
                              <p class="text-muted-foreground font-mono text-xs">
                                SKU: {{ item.sku }} • Qty: {{ item.quantity }} × &#36;{{ formatCurrency(item.price) }}
                              </p>
                            </div>
                            <div class="text-foreground font-mono text-xs font-semibold tabular-nums">
                              &#36;{{ formatCurrency(item.price * item.quantity) }}
                            </div>
                          </div>
                        }
                      </div>
                    </div>
    
                    <!-- Cost Breakdown -->
                    <div class="bg-card space-y-2 rounded-lg border p-4 text-xs">
                      <div class="text-muted-foreground flex justify-between">
                        <span>Subtotal</span>
                        <span class="text-foreground font-mono tabular-nums"
                          >&#36;{{ formatCurrency(detail.subtotal) }}</span
                        >
                      </div>
                      <div class="text-muted-foreground flex justify-between">
                        <span>Estimated Shipping</span>
                        <span class="text-foreground font-mono tabular-nums">
                          {{ detail.shipping === 0 ? 'Free' : '&#36;' + formatCurrency(detail.shipping) }}
                        </span>
                      </div>
                      <div class="text-muted-foreground flex justify-between">
                        <span>Tax</span>
                        <span class="text-foreground font-mono tabular-nums">&#36;{{ formatCurrency(detail.tax) }}</span>
                      </div>
                      <div class="text-foreground flex justify-between border-t pt-2 text-sm font-medium">
                        <span>Total</span>
                        <span class="text-foreground font-mono font-bold tabular-nums">
                          &#36;{{ formatCurrency(detail.total) }}
                        </span>
                      </div>
                    </div>
                  </div>
    
                  <!-- Sheet Footer -->
                  <div
                    ui-sheet-footer
                    class="bg-muted/10 flex flex-row items-center justify-between gap-2 border-t px-6 py-4"
                  >
                    <button ui-button aria-label="Download attachment" variant="outline" size="sm" class="text-xs">
                      <lucide-icon [img]="Download" class="mr-1.5 size-3.5" aria-hidden="true" />
                      Invoice PDF
                    </button>
                    <button ui-button ui-sheet-close variant="secondary" size="sm" class="text-xs">Close</button>
                  </div>
                </div>
              }
            </div>
          </div>
        </div>
      `,
    })
    export class UiOrderManagementPageComponent {
      protected readonly ArrowUpRight = ArrowUpRight
      protected readonly Calendar = Calendar
      protected readonly ChevronDown = ChevronDown
      protected readonly Clock = Clock
      protected readonly DollarSign = DollarSign
      protected readonly Download = Download
      protected readonly Eye = Eye
      protected readonly FileText = FileText
      protected readonly MoreHorizontal = MoreHorizontal
      protected readonly PackageCheck = PackageCheck
      protected readonly Plus = Plus
      protected readonly RotateCcw = RotateCcw
      protected readonly Search = Search
      protected readonly ShoppingCart = ShoppingCart
      protected readonly TrendingUp = TrendingUp
      protected readonly Truck = Truck
    
      protected readonly statusBadgeConfig = statusBadgeConfig
    
      @Input('class') className?: string
    
      readonly search = signal('')
      readonly statusFilter = signal<'all' | OrderStatus>('all')
      readonly selected = signal<Set<string>>(new Set())
      readonly selectedOrder = signal<Order | null>(null)
      readonly drawerOpen = signal(false)
    
      readonly filteredOrders = computed(() => {
        const query = this.search().trim().toLowerCase()
        return orders.filter((order) => {
          const matchesStatus = this.statusFilter() === 'all' || order.status === this.statusFilter()
          const matchesQuery =
            !query ||
            order.orderNumber.toLowerCase().includes(query) ||
            order.customer.name.toLowerCase().includes(query) ||
            order.customer.email.toLowerCase().includes(query)
          return matchesStatus && matchesQuery
        })
      })
    
      readonly statusCounts = computed(() => {
        const counts: Record<string, number> = { all: orders.length, paid: 0, processing: 0, shipped: 0, refunded: 0 }
        for (const ord of orders) counts[ord.status] = (counts[ord.status] ?? 0) + 1
        return counts
      })
    
      readonly statusOptions = computed(() => [
        { value: 'all', label: 'All', count: this.statusCounts().all },
        { value: 'paid', label: 'Paid', count: this.statusCounts().paid },
        { value: 'processing', label: 'Processing', count: this.statusCounts().processing },
        { value: 'shipped', label: 'Shipped', count: this.statusCounts().shipped },
        { value: 'refunded', label: 'Refunded', count: this.statusCounts().refunded },
      ])
    
      readonly allSelected = computed(
        () => this.filteredOrders().length > 0 && this.filteredOrders().every((ord) => this.selected().has(ord.id)),
      )
      readonly someSelected = computed(() => this.filteredOrders().some((ord) => this.selected().has(ord.id)))
    
      get rootClass(): string {
        return cn('w-full space-y-6', this.className)
      }
    
      statusPillClass(value: string): string {
        return cn(
          'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
          this.statusFilter() === value
            ? 'bg-background text-foreground shadow-xs'
            : 'text-muted-foreground hover:text-foreground',
        )
      }
    
      statusCountClass(value: string): string {
        return cn(
          'ml-1.5 rounded-full px-1.5 py-0.5 text-xs font-normal tabular-nums',
          this.statusFilter() === value ? 'bg-muted text-foreground' : 'text-muted-foreground',
        )
      }
    
      statusBadgeClass(status: OrderStatus): string {
        return cn('gap-1.5 px-2 py-0.5 text-xs font-medium capitalize', statusBadgeConfig[status].class)
      }
    
      statusBadgeDrawerClass(status: OrderStatus): string {
        return cn('gap-1.5 px-2.5 py-0.5 text-xs font-medium capitalize', statusBadgeConfig[status].class)
      }
    
      statusDotClass(status: OrderStatus): string {
        return cn('size-1.5 rounded-full', statusBadgeConfig[status].dot)
      }
    
      setStatusFilter(value: string): void {
        this.statusFilter.set(value as 'all' | OrderStatus)
      }
    
      itemCount(order: Order): number {
        return order.items.reduce((acc, item) => acc + item.quantity, 0)
      }
    
      toggleAll(checked: boolean): void {
        this.selected.set(checked ? new Set(this.filteredOrders().map((ord) => ord.id)) : new Set())
      }
    
      toggleRow(id: string, checked: boolean): void {
        const next = new Set(this.selected())
        if (checked) next.add(id)
        else next.delete(id)
        this.selected.set(next)
      }
    
      clearSelection(): void {
        this.selected.set(new Set())
      }
    
      exportSelected(): void {
        this.clearSelection()
      }
    
      markSelectedShipped(): void {
        this.clearSelection()
      }
    
      openOrderDetails(order: Order): void {
        this.selectedOrder.set(order)
        this.drawerOpen.set(true)
      }
    
      formatCurrency(val: number): string {
        return val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
      }
    
      initials(name: string): string {
        return name
          .split(' ')
          .map((p) => p[0])
          .slice(0, 2)
          .join('')
          .toUpperCase()
      }
    }
    
  • src/app/components/blocks/order-management-page/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/order-management-page.json