
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
B2B wholesale bulk order matrix with volume tier pricing, case pack multipliers, and CSV SKU paste input.
Also available for React ->$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/wholesale-b2b-quick-order.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/wholesale-b2b-quick-order.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/wholesale-b2b-quick-order.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/wholesale-b2b-quick-order.jsonnpx shadcn-vue@latest add @uipkge/wholesale-b2b-quick-orderInstalls to:app/components/blocks/| Name | Type / Values | Default | Required |
|---|---|---|---|
poNumber | string | 'PO-2026-8841B' | optional |
accountName | string | 'Northwind Retailers' | optional |
accountTier | string | 'Tier 3 Wholesale Partner' | optional |
tierDiscountPercent | number | 35 | optional |
paymentTerms | string | 'Net 30 Invoicing' | optional |
freeFreightThreshold | number | 10000 | optional |
initialProducts | WholesaleProduct[] | — | optional |
class | HTMLAttributes['class'] | — | optional |
<script lang="ts">
export interface WholesaleProduct {
id: string
sku: string
name: string
variant: string
casePack: number
msrpUnitPrice: number
wholesaleUnitPrice: number
cases: number
availableCases: number
image: string
category: string
}
export const DEFAULT_WHOLESALE_PRODUCTS: WholesaleProduct[] = [
{
id: 'prod-1',
sku: 'SKU-84920',
name: 'Pro Studio Headphones',
variant: 'Matte Black · Pro Series',
casePack: 12,
msrpUnitPrice: 299.0,
wholesaleUnitPrice: 194.35,
cases: 4,
availableCases: 450,
image: 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=300&auto=format&fit=crop&q=80',
category: 'Audio & Acoustics',
},
{
id: 'prod-2',
sku: 'SKU-49102',
name: 'Aero Minimalist Runner',
variant: 'Arctic White · US 10-12 Assorted',
casePack: 10,
msrpUnitPrice: 140.0,
wholesaleUnitPrice: 91.0,
cases: 3,
availableCases: 180,
image: 'https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=300&auto=format&fit=crop&q=80',
category: 'Footwear',
},
{
id: 'prod-3',
sku: 'SKU-77215',
name: 'Technical Shell Parka',
variant: 'Mineral Gray · Waterproof 3L',
casePack: 8,
msrpUnitPrice: 220.0,
wholesaleUnitPrice: 143.0,
cases: 2,
availableCases: 95,
image: 'https://images.unsplash.com/photo-1591047139829-d91aecb6caea?w=300&auto=format&fit=crop&q=80',
category: 'Outerwear',
},
{
id: 'prod-4',
sku: 'SKU-10934',
name: 'Braided USB-C Cable',
variant: 'Space Gray · 2m 240W EPR',
casePack: 24,
msrpUnitPrice: 25.0,
wholesaleUnitPrice: 16.25,
cases: 2,
availableCases: 620,
image: 'https://images.unsplash.com/photo-1583863788434-e58a36330cf0?w=300&auto=format&fit=crop&q=80',
category: 'Hardware & Cables',
},
{
id: 'prod-5',
sku: 'SKU-63821',
name: 'Leather Cardholder',
variant: 'Saddle Brown · Full-Grain Veg-Tan',
casePack: 20,
msrpUnitPrice: 50.0,
wholesaleUnitPrice: 32.5,
cases: 1,
availableCases: 24,
image: 'https://images.unsplash.com/photo-1627123424574-724758594e93?w=300&auto=format&fit=crop&q=80',
category: 'Leather Goods',
},
]
</script>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { HTMLAttributes } from 'vue'
import {
AlertCircle,
Bookmark,
Building2,
CheckCircle2,
Download,
FileSpreadsheet,
Minus,
Package,
Plus,
RefreshCw,
Send,
Layers,
Trash2,
TrendingDown,
Truck,
Upload,
X,
} from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
interface Props {
poNumber?: string
accountName?: string
accountTier?: string
tierDiscountPercent?: number
paymentTerms?: string
freeFreightThreshold?: number
initialProducts?: WholesaleProduct[]
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
poNumber: 'PO-2026-8841B',
accountName: 'Northwind Retailers',
accountTier: 'Tier 3 Wholesale Partner',
tierDiscountPercent: 35,
paymentTerms: 'Net 30 Invoicing',
freeFreightThreshold: 10000,
})
const emit = defineEmits<{
submitOrder: [
payload: {
poNumber: string
items: WholesaleProduct[]
subtotal: number
totalUnits: number
totalCases: number
savings: number
},
]
saveTemplate: [items: WholesaleProduct[]]
exportCsv: [items: WholesaleProduct[]]
}>()
const products = ref<WholesaleProduct[]>(
props.initialProducts
? JSON.parse(JSON.stringify(props.initialProducts))
: JSON.parse(JSON.stringify(DEFAULT_WHOLESALE_PRODUCTS)),
)
const poRef = ref(props.poNumber)
const showCsvBox = ref(false)
const csvPasteText = ref('')
const pasteFeedback = ref<{ type: 'success' | 'error'; message: string } | null>(null)
const templateSaved = ref(false)
const orderSubmitted = ref(false)
const isSubmitting = ref(false)
const formatCurrency = (val: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(val)
}
const formatNumber = (val: number) => {
return new Intl.NumberFormat('en-US').format(val)
}
// Computations
const totalCases = computed(() => products.value.reduce((acc, p) => acc + (Number(p.cases) || 0), 0))
const totalUnits = computed(() => products.value.reduce((acc, p) => acc + (Number(p.cases) || 0) * p.casePack, 0))
const wholesaleSubtotal = computed(() =>
products.value.reduce((acc, p) => acc + (Number(p.cases) || 0) * p.casePack * p.wholesaleUnitPrice, 0),
)
const msrpTotal = computed(() =>
products.value.reduce((acc, p) => acc + (Number(p.cases) || 0) * p.casePack * p.msrpUnitPrice, 0),
)
const totalSavings = computed(() => Math.max(0, msrpTotal.value - wholesaleSubtotal.value))
const savingsPercent = computed(() => {
if (msrpTotal.value <= 0) return props.tierDiscountPercent
return Math.round((totalSavings.value / msrpTotal.value) * 100)
})
const isFreeFreight = computed(() => wholesaleSubtotal.value >= props.freeFreightThreshold)
const freightAmount = computed(() => (isFreeFreight.value ? 0 : 250))
const grandTotal = computed(() => wholesaleSubtotal.value + freightAmount.value)
const nextTierTarget = 50000
const nextTierRemaining = computed(() => Math.max(0, nextTierTarget - wholesaleSubtotal.value))
const tierProgressPercent = computed(() => Math.min(100, Math.round((wholesaleSubtotal.value / nextTierTarget) * 100)))
// Actions
const updateCases = (id: string, delta: number) => {
const item = products.value.find((p) => p.id === id)
if (item) {
const current = Number(item.cases) || 0
const next = Math.max(0, Math.min(item.availableCases, current + delta))
item.cases = next
}
}
const setCases = (id: string, val: string | number) => {
const item = products.value.find((p) => p.id === id)
if (item) {
const parsed = parseInt(String(val), 10)
if (isNaN(parsed) || parsed < 0) {
item.cases = 0
} else {
item.cases = Math.min(item.availableCases, parsed)
}
}
}
const clearLine = (id: string) => {
const item = products.value.find((p) => p.id === id)
if (item) {
item.cases = 0
}
}
const resetAllCases = () => {
products.value.forEach((p) => {
p.cases = 0
})
pasteFeedback.value = null
}
const restoreDefaultCases = () => {
products.value = JSON.parse(JSON.stringify(DEFAULT_WHOLESALE_PRODUCTS))
pasteFeedback.value = null
}
const applyCsvPaste = () => {
pasteFeedback.value = null
if (!csvPasteText.value.trim()) {
pasteFeedback.value = {
type: 'error',
message: 'Please paste SKU and case quantity rows to parse.',
}
return
}
const lines = csvPasteText.value.split(/\r?\n/)
let updatedCount = 0
const unknownSkus: string[] = []
for (const rawLine of lines) {
const line = rawLine.trim()
if (!line || line.startsWith('#') || line.toLowerCase().startsWith('sku')) continue
const parts = line.split(/[,;\t]+/).map((s) => s.trim())
if (parts.length >= 2) {
const skuQuery = parts[0].toUpperCase()
const qty = parseInt(parts[1], 10)
if (!isNaN(qty)) {
const product = products.value.find(
(p) => p.sku.toUpperCase() === skuQuery || p.sku.toUpperCase().includes(skuQuery),
)
if (product) {
product.cases = Math.max(0, Math.min(product.availableCases, qty))
updatedCount++
} else {
unknownSkus.push(parts[0])
}
}
}
}
if (updatedCount > 0) {
pasteFeedback.value = {
type: 'success',
message: `Successfully updated ${updatedCount} SKU line items in bulk matrix.${
unknownSkus.length > 0
? ` Note: ${unknownSkus.length} SKU(s) not found in catalog (${unknownSkus.slice(0, 3).join(', ')}).`
: ''
}`,
}
} else {
pasteFeedback.value = {
type: 'error',
message: 'No matching catalog SKUs found. Verify format: SKU-84920, 10',
}
}
}
const loadSampleCsv = () => {
csvPasteText.value = `SKU-84920, 8\nSKU-49102, 5\nSKU-77215, 4\nSKU-10934, 12\nSKU-63821, 6`
applyCsvPaste()
}
const handleSaveTemplate = () => {
templateSaved.value = true
emit('saveTemplate', products.value)
setTimeout(() => {
templateSaved.value = false
}, 4000)
}
const handleSubmitOrder = () => {
if (totalCases.value === 0) return
isSubmitting.value = true
setTimeout(() => {
isSubmitting.value = false
orderSubmitted.value = true
emit('submitOrder', {
poNumber: poRef.value,
items: products.value.filter((p) => p.cases > 0),
subtotal: wholesaleSubtotal.value,
totalUnits: totalUnits.value,
totalCases: totalCases.value,
savings: totalSavings.value,
})
}, 600)
}
const handleExportCsv = () => {
emit('exportCsv', products.value)
const header =
'SKU,Product Name,Variant,Case Pack Multiplier,Cases Ordered,Total Units,Wholesale Unit Price,Line Total\n'
const rows = products.value
.filter((p) => p.cases > 0)
.map(
(p) =>
`"${p.sku}","${p.name}","${p.variant}",${p.casePack},${p.cases},${p.cases * p.casePack},${p.wholesaleUnitPrice},${(p.cases * p.casePack * p.wholesaleUnitPrice).toFixed(2)}`,
)
.join('\n')
const csvContent = 'data:text/csv;charset=utf-8,' + encodeURIComponent(header + rows)
const downloadLink = document.createElement('a')
downloadLink.setAttribute('href', csvContent)
downloadLink.setAttribute('download', `${poRef.value}-order-matrix.csv`)
document.body.appendChild(downloadLink)
downloadLink.click()
document.body.removeChild(downloadLink)
}
</script>
<template>
<div data-slot="wholesale-b2b-quick-order" :class="cn('bg-background text-foreground w-full space-y-6', props.class)">
<!-- Header & Account Info -->
<Card class="border-border bg-card shadow-xs">
<CardHeader class="pb-4">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="space-y-1.5">
<div class="flex flex-wrap items-center gap-2">
<div class="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
<Building2 class="size-3.5" />
<span>{{ accountName }}</span>
<span>·</span>
<span>{{ accountTier }}</span>
</div>
<Badge variant="outline" class="border-success/30 bg-success/10 text-success font-medium">
<Layers class="mr-1 size-3" />
{{ tierDiscountPercent }}% Off MSRP Wholesale Tier
</Badge>
</div>
<CardTitle class="text-xl font-bold tracking-tight sm:text-2xl">
Wholesale & B2B Bulk Order Matrix
</CardTitle>
<CardDescription class="text-muted-foreground text-xs sm:text-sm">
Build your purchase order by entering case quantities below or pasting bulk SKU manifests with automated
case multipliers.
</CardDescription>
</div>
<!-- Header Action Buttons -->
<div class="flex flex-wrap items-center gap-2">
<Button
aria-label="Close CSV paste"
variant="outline"
size="sm"
class="gap-1.5 text-xs font-medium"
@click="showCsvBox = !showCsvBox"
>
<FileSpreadsheet class="size-3.5" />
<span>{{ showCsvBox ? 'Hide CSV Paste' : 'Upload CSV Order' }}</span>
</Button>
<Button variant="outline" size="sm" class="gap-1.5 text-xs font-medium" @click="handleSaveTemplate">
<Bookmark class="size-3.5" />
<span>Save Order Template</span>
</Button>
</div>
</div>
</CardHeader>
<CardContent class="pt-0">
<!-- Account Terms & Partner Meta Strip -->
<div class="bg-muted/40 border-border grid grid-cols-2 gap-3 rounded-lg border p-3 sm:grid-cols-4 sm:gap-4">
<div class="space-y-0.5">
<div class="text-muted-foreground text-xs font-medium">PO Reference</div>
<div class="font-mono text-sm font-semibold">{{ poRef }}</div>
</div>
<div class="space-y-0.5">
<div class="text-muted-foreground text-xs font-medium">Payment Terms</div>
<div class="text-foreground flex items-center gap-1 text-sm font-semibold">
<CheckCircle2 class="text-success size-3.5" />
<span>{{ paymentTerms }}</span>
</div>
</div>
<div class="space-y-0.5">
<div class="text-muted-foreground text-xs font-medium">Freight Status</div>
<div :class="cn('text-sm font-semibold tabular-nums', isFreeFreight ? 'text-success' : 'text-foreground')">
{{ isFreeFreight ? 'Free Ground Freight' : '$250 Standard Freight' }}
</div>
</div>
<div class="space-y-0.5">
<div class="text-muted-foreground text-xs font-medium">Volume Tier Status</div>
<div class="text-foreground text-sm font-semibold">
Tier 3 <span class="text-muted-foreground font-normal">({{ tierDiscountPercent }}% Margin)</span>
</div>
</div>
</div>
<!-- Volume Tier Margin Incentive Banner -->
<div class="border-border bg-card/60 mt-3 rounded-lg border p-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-2">
<TrendingDown class="text-success size-4 shrink-0" />
<div class="text-xs">
<span class="text-foreground font-semibold">Tier 4 Milestone ($50,000):</span>
<span class="text-muted-foreground ml-1">
{{
nextTierRemaining > 0
? `Add ${formatCurrency(nextTierRemaining)} more to unlock 42% Tier 4 Enterprise Margin.`
: 'Tier 4 Enterprise Margin (42%) Unlocked!'
}}
</span>
</div>
</div>
<div class="text-muted-foreground flex items-center gap-2 text-xs">
<span>{{ tierProgressPercent }}% to Tier 4</span>
<div class="bg-muted border-border h-2 w-24 overflow-hidden rounded-full border">
<div
class="bg-success h-full transition-[width] duration-300"
:style="{ width: `${tierProgressPercent}%` }"
/>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Template Saved Alert -->
<div
v-if="templateSaved"
class="border-success/30 bg-success/10 text-success text-success flex items-center justify-between rounded-lg border p-3 text-xs sm:text-sm"
>
<div class="flex items-center gap-2">
<Bookmark class="text-success size-4 shrink-0" />
<span>Order matrix saved as your primary template. You can re-populate this draft at any time.</span>
</div>
<Button
aria-label="Dismiss notification"
variant="ghost"
size="xs"
class="text-success hover:text-success"
@click="templateSaved = false"
>
<X class="size-3.5" />
</Button>
</div>
<!-- Order Submitted Success Alert -->
<div
v-if="orderSubmitted"
class="border-success/30 bg-success/10 text-success dark:text-foreground rounded-xl border p-4 sm:p-5"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-start gap-3">
<CheckCircle2 class="text-success mt-0.5 size-5 shrink-0" />
<div class="space-y-1">
<h3 class="text-sm font-semibold sm:text-base">Purchase Order {{ poRef }} Submitted Successfully!</h3>
<p class="text-muted-foreground text-xs sm:text-sm">
Your bulk order of
<strong class="text-foreground">{{ totalCases }} cases ({{ totalUnits }} units)</strong> totaling
<strong class="text-foreground">{{ formatCurrency(grandTotal) }}</strong> has been queued for warehouse
dispatch under {{ paymentTerms }}.
</p>
</div>
</div>
<div class="flex items-center gap-2">
<Button aria-label="Download attachment" variant="outline" size="sm" class="text-xs" @click="handleExportCsv">
<Download class="mr-1.5 size-3.5" />
Download PO PDF
</Button>
<Button size="sm" class="text-xs" @click="orderSubmitted = false"> Start New Order </Button>
</div>
</div>
</div>
<!-- Quick SKU & Quantity CSV Paste Box -->
<Card v-if="showCsvBox" class="border-border bg-card shadow-xs transition-colors duration-200">
<CardHeader class="pb-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<FileSpreadsheet class="text-primary size-4" />
<CardTitle class="text-sm font-semibold sm:text-base"> Quick Paste SKU & Quantity Manifest </CardTitle>
</div>
<Button
aria-label="Close CSV paste"
variant="ghost"
size="xs"
class="text-muted-foreground hover:text-foreground"
@click="showCsvBox = false"
>
<X class="size-4" />
</Button>
</div>
<CardDescription class="text-muted-foreground text-xs">
Paste CSV rows with SKU identifier and case quantity (one item per line, e.g.
<code class="bg-muted rounded px-1 font-mono">SKU-84920, 10</code>) to auto-populate matrix.
</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
<Textarea
v-model="csvPasteText"
placeholder="SKU-84920, 8 SKU-49102, 5 SKU-77215, 4 SKU-10934, 12 SKU-63821, 6"
:rows="4"
class="font-mono text-xs"
/>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<Button size="sm" class="text-xs font-medium" @click="applyCsvPaste">
<Upload class="mr-1.5 size-3.5" />
Apply SKU List to Matrix
</Button>
<Button variant="outline" size="sm" class="text-xs font-medium" @click="loadSampleCsv">
Load Sample Data
</Button>
<Button
v-if="csvPasteText"
variant="ghost"
size="sm"
class="text-muted-foreground text-xs"
@click="csvPasteText = ''"
>
Clear Input
</Button>
</div>
<div class="text-muted-foreground text-xs">Supports comma, tab, and semicolon delimited lines</div>
</div>
<!-- Parse Feedback Alert -->
<div
v-if="pasteFeedback"
:class="
cn(
'flex items-center gap-2 rounded-lg border p-2.5 text-xs',
pasteFeedback.type === 'success'
? 'border-success/30 bg-success/10 text-success'
: 'border-destructive/30 bg-destructive/10 text-destructive',
)
"
>
<CheckCircle2 v-if="pasteFeedback.type === 'success'" class="text-success size-4 shrink-0" />
<AlertCircle v-else class="size-4 shrink-0" />
<span>{{ pasteFeedback.message }}</span>
</div>
</CardContent>
</Card>
<!-- Bulk SKU Matrix Table Card -->
<Card class="border-border bg-card shadow-xs">
<CardHeader class="border-border border-b pb-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle class="text-base font-semibold">Bulk SKU Order Matrix</CardTitle>
<CardDescription class="text-muted-foreground text-xs">
{{ products.length }} wholesale catalog items available with active case multipliers.
</CardDescription>
</div>
<div class="flex items-center gap-2">
<Button variant="outline" size="xs" class="text-muted-foreground text-xs" @click="restoreDefaultCases">
<RefreshCw class="mr-1 size-3" />
Reset Defaults
</Button>
<Button variant="outline" size="xs" class="text-muted-foreground text-xs" @click="resetAllCases">
<Trash2 class="mr-1 size-3" />
Zero All
</Button>
</div>
</div>
</CardHeader>
<CardContent class="p-0">
<div class="overflow-x-auto">
<Table class="w-full max-w-[760px] min-w-full">
<TableHeader>
<TableRow class="bg-muted/30">
<TableHead class="w-[300px] text-xs font-semibold">Product & SKU Identifier</TableHead>
<TableHead class="text-xs font-semibold">Case Multiplier</TableHead>
<TableHead class="text-xs font-semibold">Unit Price</TableHead>
<TableHead class="text-xs font-semibold">Case Quantity</TableHead>
<TableHead class="text-xs font-semibold">Availability</TableHead>
<TableHead class="text-right text-xs font-semibold">Line Total</TableHead>
<TableHead class="w-[50px]">
<span class="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="product in products"
:key="product.id"
:class="cn('transition-colors', product.cases > 0 && 'bg-primary/5 dark:bg-primary/5')"
>
<!-- Product Thumbnail & Details -->
<TableCell class="py-3.5">
<div class="flex items-center gap-3">
<img
:src="product.image"
:alt="product.name"
class="border-border bg-muted/40 size-12 shrink-0 rounded-lg border object-cover"
loading="lazy"
/>
<div class="min-w-0 space-y-0.5">
<div class="text-foreground truncate text-sm font-medium">
{{ product.name }}
</div>
<div class="text-muted-foreground flex items-center gap-1.5 text-xs">
<span class="font-mono">{{ product.sku }}</span>
<span>·</span>
<span class="truncate">{{ product.variant }}</span>
</div>
</div>
</div>
</TableCell>
<!-- Case Pack Multiplier -->
<TableCell class="py-3.5">
<div class="flex items-center gap-1.5">
<Package class="text-muted-foreground size-3.5" />
<span class="text-foreground font-mono text-xs font-medium tabular-nums">
{{ product.casePack }} units / case
</span>
</div>
</TableCell>
<!-- Pricing (MSRP vs Wholesale) -->
<TableCell class="py-3.5">
<div class="space-y-0.5">
<div class="text-foreground text-sm font-semibold tabular-nums">
{{ formatCurrency(product.wholesaleUnitPrice) }}
<span class="text-muted-foreground text-xs font-normal">wholesale</span>
</div>
<div class="text-muted-foreground flex items-center gap-1.5 text-xs">
<span class="tabular-nums line-through">{{ formatCurrency(product.msrpUnitPrice) }} MSRP</span>
<span class="text-success font-medium">
-{{
Math.round(
((product.msrpUnitPrice - product.wholesaleUnitPrice) / product.msrpUnitPrice) * 100,
)
}}%
</span>
</div>
</div>
</TableCell>
<!-- Quantity Stepper Input -->
<TableCell class="py-3.5">
<div class="space-y-1">
<div class="flex items-center gap-1">
<Button
aria-label="Decrease cases"
variant="outline"
size="xs"
class="size-7 p-0"
:disabled="product.cases <= 0"
@click="updateCases(product.id, -1)"
>
<Minus class="size-3" />
</Button>
<Input
:model-value="product.cases"
type="number"
min="0"
:max="product.availableCases"
class="h-7 w-16 text-center font-mono text-xs tabular-nums"
@update:model-value="(val) => setCases(product.id, val)"
/>
<Button
aria-label="Increase cases"
variant="outline"
size="xs"
class="size-7 p-0"
:disabled="product.cases >= product.availableCases"
@click="updateCases(product.id, 1)"
>
<Plus class="size-3" />
</Button>
</div>
<div class="text-muted-foreground font-mono text-xs tabular-nums">
{{ product.cases }} {{ product.cases === 1 ? 'case' : 'cases' }} =
<strong class="text-foreground">{{ product.cases * product.casePack }}</strong> units
</div>
</div>
</TableCell>
<!-- Stock Status -->
<TableCell class="py-3.5">
<Badge
v-if="product.availableCases > 50"
variant="outline"
class="border-success/30 bg-success/10 text-success text-xs font-medium"
>
In Stock · {{ formatNumber(product.availableCases) }} cases
</Badge>
<Badge
v-else
variant="outline"
class="border-warning/30 bg-warning/10 text-warning text-xs font-medium"
>
Low Stock · {{ product.availableCases }} cases
</Badge>
</TableCell>
<!-- Line Total -->
<TableCell class="py-3.5 text-right">
<div class="space-y-0.5">
<div class="text-foreground font-mono text-sm font-bold tabular-nums">
{{ formatCurrency(product.cases * product.casePack * product.wholesaleUnitPrice) }}
</div>
<div v-if="product.cases > 0" class="text-success text-success font-mono text-xs tabular-nums">
Save
{{
formatCurrency(
product.cases * product.casePack * (product.msrpUnitPrice - product.wholesaleUnitPrice),
)
}}
</div>
<div v-else class="text-muted-foreground text-xs">0 units</div>
</div>
</TableCell>
<!-- Row Clear Action -->
<TableCell class="py-3.5 text-center">
<Button
v-if="product.cases > 0"
variant="ghost"
size="xs"
class="text-muted-foreground hover:text-destructive size-7 p-0"
title="Clear row quantity"
@click="clearLine(product.id)"
>
<Trash2 class="size-3.5" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<!-- Sticky Order Summary Footer -->
<div
class="bg-card/95 border-border sticky bottom-4 z-20 rounded-xl border p-4 shadow-lg backdrop-blur-md transition-colors sm:p-5"
>
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<!-- Summary Totals Metrics -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 sm:gap-6">
<!-- Total Units -->
<div class="space-y-0.5">
<div class="text-muted-foreground text-xs font-medium">Total Units</div>
<div class="font-mono text-base font-bold tabular-nums sm:text-lg">
{{ formatNumber(totalUnits) }} Units
</div>
<div class="text-muted-foreground font-mono text-xs tabular-nums">{{ totalCases }} Cases ordered</div>
</div>
<!-- Wholesale Subtotal -->
<div class="space-y-0.5">
<div class="text-muted-foreground text-xs font-medium">Wholesale Subtotal</div>
<div class="font-mono text-base font-bold tabular-nums sm:text-lg">
{{ formatCurrency(wholesaleSubtotal) }}
</div>
<div class="text-muted-foreground text-xs">MSRP {{ formatCurrency(msrpTotal) }}</div>
</div>
<!-- Tier Savings -->
<div class="space-y-0.5">
<div class="text-muted-foreground text-xs font-medium">Tier Volume Savings</div>
<div class="text-success text-success font-mono text-base font-bold tabular-nums sm:text-lg">
-{{ formatCurrency(totalSavings) }}
</div>
<div class="text-success text-xs font-medium">{{ savingsPercent }}% Margin Savings</div>
</div>
<!-- Freight & Terms -->
<div class="space-y-0.5">
<div class="text-muted-foreground text-xs font-medium">Freight & Terms</div>
<div class="text-foreground flex items-center gap-1.5 text-xs font-semibold">
<Truck class="text-primary size-3.5 shrink-0" />
<span>{{ isFreeFreight ? 'Free Freight (>$10k)' : '$250 Ground' }}</span>
</div>
<div class="text-muted-foreground text-xs">
{{ paymentTerms }}
</div>
</div>
</div>
<!-- Submit & Secondary Actions -->
<div class="flex flex-wrap items-center gap-2.5 pt-2 lg:pt-0">
<Button
aria-label="Download attachment"
variant="outline"
size="sm"
class="text-xs font-medium"
:disabled="totalCases === 0"
@click="handleExportCsv"
>
<Download class="mr-1.5 size-3.5" />
Export CSV
</Button>
<Button
size="default"
class="gap-2 text-xs font-semibold sm:text-sm"
:disabled="totalCases === 0 || isSubmitting"
@click="handleSubmitOrder"
>
<Send v-if="!isSubmitting" class="size-4" />
<RefreshCw v-else class="size-4 animate-spin" />
<span>{{ isSubmitting ? 'Processing PO...' : 'Submit Wholesale Purchase Order' }}</span>
</Button>
</div>
</div>
</div>
</div>
</template>
Raw manifest:https://uipkge.dev/r/vue/wholesale-b2b-quick-order.json