Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 53 additions & 25 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

31 changes: 28 additions & 3 deletions app/(dashboard)/analytics/forecast/forecast-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ export function ForecastClient({ forecasts, settings }: Props) {
const [selected, setSelected] = useState<Set<string>>(new Set())
const [filter, setFilter] = useState<'all' | 'critical' | 'low' | 'ok' | 'overstock'>('all')
const [abcFilter, setAbcFilter] = useState<'all' | 'A' | 'B' | 'C'>('all')
const [supplierFilter, setSupplierFilter] = useState('all')
const [search, setSearch] = useState('')
const [error, setError] = useState('')
const [sortField, setSortField] = useState<SortField>('daysUntilStockout')
Expand All @@ -333,6 +334,7 @@ export function ForecastClient({ forecasts, settings }: Props) {
const filtered = forecasts.filter((f) => {
if (filter !== 'all' && f.urgency !== filter) return false
if (abcFilter !== 'all' && f.abcClass !== abcFilter) return false
if (supplierFilter !== 'all' && f.supplierId !== supplierFilter) return false
if (search) {
const q = search.toLowerCase()
return f.sku.toLowerCase().includes(q) || f.name.toLowerCase().includes(q) || (f.supplierName ?? '').toLowerCase().includes(q)
Expand All @@ -346,19 +348,31 @@ export function ForecastClient({ forecasts, settings }: Props) {
const paged = sorted.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE)

const needsReorder = forecasts.filter((f) => f.urgency === 'critical' || f.urgency === 'low')
const supplierOptions = Array.from(
new Map(
forecasts
.filter((f) => f.supplierId && f.supplierName)
.map((f) => [f.supplierId!, f.supplierName!]),
),
).sort((a, b) => a[1].localeCompare(b[1]))

function toggleSelect(id: string) {
setSelected((prev) => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n })
}
function selectAllNeedingReorder() {
setSelected(new Set(needsReorder.filter((f) => f.supplierId).map((f) => f.productId)))
setSelected(new Set(
needsReorder
.filter((f) => f.supplierId && (supplierFilter === 'all' || f.supplierId === supplierFilter))
.map((f) => f.productId),
))
}

function handleCreatePOs() {
if (selected.size === 0) { setError('Select at least one product'); return }
if (supplierFilter === 'all') { setError('Choose one supplier before creating a draft PO'); return }
setError('')
startTransition(async () => {
const result = await createReorderPOs(Array.from(selected))
const result = await createReorderPOs(Array.from(selected), { supplierId: supplierFilter })
if (result.success) {
setSelected(new Set())
router.refresh()
Expand Down Expand Up @@ -428,6 +442,17 @@ export function ForecastClient({ forecasts, settings }: Props) {
))}
</div>
<span className="w-px h-5 bg-border" />
<select
className="h-7 rounded-md border border-input bg-background px-2 text-xs"
value={supplierFilter}
onChange={(e) => { setSupplierFilter(e.target.value); setSelected(new Set()); setPage(1) }}
>
<option value="all">All suppliers</option>
{supplierOptions.map(([id, name]) => (
<option key={id} value={id}>{name}</option>
))}
</select>
<span className="w-px h-5 bg-border" />
<div className="flex gap-1">
{(['all', 'A', 'B', 'C'] as const).map((c) => (
<Button key={c} variant={abcFilter === c ? 'default' : 'outline'} size="sm" className="h-7 text-xs" onClick={() => { setAbcFilter(c); setPage(1) }}>
Expand All @@ -439,7 +464,7 @@ export function ForecastClient({ forecasts, settings }: Props) {
<>
<span className="w-px h-5 bg-border" />
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={selectAllNeedingReorder}>
Select all needing reorder ({needsReorder.filter((f) => f.supplierId).length})
Select all needing reorder ({needsReorder.filter((f) => f.supplierId && (supplierFilter === 'all' || f.supplierId === supplierFilter)).length})
</Button>
</>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const ONHAND_FIELDS: FieldDef[] = [
{ key: 'barcode', label: 'Barcode', type: 'text' },
{ key: 'mpn', label: 'MPN', type: 'text' },
{ key: 'warehouseCode', label: 'Warehouse', type: 'text' },
{ key: 'lifecycleStatus', label: 'Status', type: 'select', options: ['ACTIVE', 'NOT_FOR_SALE', 'ARCHIVED'] },
{ key: 'lifecycleStatus', label: 'Status', type: 'select', options: ['DRAFT', 'ACTIVE', 'EOL', 'ARCHIVED'] },
{ key: 'quantity', label: 'Quantity', type: 'number' },
{ key: 'reservedQty', label: 'Reserved', type: 'number' },
{ key: 'available', label: 'Available', type: 'number' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ type SortDir = 'asc' | 'desc'

const LIFECYCLE_OPTIONS = [
{ value: 'ACTIVE', label: 'Active' },
{ value: 'NOT_FOR_SALE', label: 'Not for Sale' },
{ value: 'DRAFT', label: 'Draft' },
{ value: 'EOL', label: 'End of Life' },
{ value: 'ARCHIVED', label: 'Archived' },
] as const

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const PRODUCT_FIELDS: FieldDef[] = [
{ key: 'stockUnit', label: 'Stock Unit', type: 'text' },
{ key: 'barcode', label: 'Barcode', type: 'text' },
{ key: 'mpn', label: 'MPN', type: 'text' },
{ key: 'lifecycleStatus', label: 'Status', type: 'select', options: ['ACTIVE', 'NOT_FOR_SALE', 'ARCHIVED'] },
{ key: 'lifecycleStatus', label: 'Status', type: 'select', options: ['DRAFT', 'ACTIVE', 'EOL', 'ARCHIVED'] },
{ key: 'qtySold', label: 'Qty Sold', type: 'number' },
{ key: 'qtyRefunded', label: 'Qty Refunded', type: 'number' },
{ key: 'netQty', label: 'Net Qty', type: 'number' },
Expand Down
27 changes: 21 additions & 6 deletions app/(dashboard)/inventory/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Link from 'next/link'
import { notFound } from 'next/navigation'
import { ChevronRight, Package, Layers, SlidersHorizontal } from 'lucide-react'
import { db } from '@/lib/db'
import { getProduct, getVariableProducts, listProductCategories, updateProduct, getProductOptions, getProductSuppliers, getProductComponents, getKitStock } from '@/app/actions/products'
import { getProduct, getVariableProducts, listProductCategories, listProductSupplierOptions, updateProduct, getProductOptions, getProductSuppliers, getProductComponents, getKitStock } from '@/app/actions/products'
import { getWarehouses, getActiveAdjustmentReasons } from '@/app/actions/stock'
import { getStockUnitOptions } from '@/app/actions/settings'
import { ProductForm } from '@/components/inventory/product-form'
Expand Down Expand Up @@ -30,14 +30,16 @@ const TYPE_LABELS: Record<ProductType, string> = {
}

const STATUS_LABELS: Record<ProductLifecycleStatus, string> = {
DRAFT: 'Draft',
ACTIVE: 'Active',
NOT_FOR_SALE: 'Not for sale',
EOL: 'End of life',
ARCHIVED: 'Archived',
}

const STATUS_VARIANTS: Record<ProductLifecycleStatus, 'default' | 'secondary' | 'outline'> = {
DRAFT: 'secondary',
ACTIVE: 'default',
NOT_FOR_SALE: 'secondary',
EOL: 'secondary',
ARCHIVED: 'outline',
}

Expand All @@ -54,13 +56,14 @@ export default async function ProductDetailPage({
params: Promise<{ id: string }>
}) {
const { id } = await params
const [product, variableProducts, warehouses, reasons, stockUnitOptions, productCategories] = await Promise.all([
const [product, variableProducts, warehouses, reasons, stockUnitOptions, productCategories, supplierOptions] = await Promise.all([
getProduct(id),
getVariableProducts(),
getWarehouses(),
getActiveAdjustmentReasons(),
getStockUnitOptions(),
listProductCategories(),
listProductSupplierOptions(),
])
const baseCurrency = await getBaseCurrencyDisplay()
const fmtBase = (value: number) => formatMoney(value, baseCurrency.symbol, baseCurrency.symbolPosition)
Expand All @@ -81,7 +84,7 @@ export default async function ProductDetailPage({
// For the kit configurator: all stockable products (not self, not VARIABLE, not NON_INVENTORY)
const allSimpleProducts = isKitOrBom
? await db.product.findMany({
where: { lifecycleStatus: { in: ['ACTIVE', 'NOT_FOR_SALE'] }, type: { notIn: ['VARIABLE', 'NON_INVENTORY'] }, NOT: { id } },
where: { lifecycleStatus: { in: ['DRAFT', 'ACTIVE', 'EOL'] }, type: { notIn: ['VARIABLE', 'NON_INVENTORY'] }, NOT: { id } },
select: { id: true, sku: true, name: true },
orderBy: { sku: 'asc' },
})
Expand Down Expand Up @@ -142,6 +145,8 @@ export default async function ProductDetailPage({
description: product.description ?? undefined,
type: product.type,
parentId: product.parentId ?? undefined,
preferredSupplierId: product.preferredSupplierId,
preferredSupplierLocked: product.preferredSupplierLocked,
barcode: product.barcode ?? undefined,
mpn: product.mpn ?? undefined,
hsCode: product.hsCode ?? undefined,
Expand All @@ -162,6 +167,7 @@ export default async function ProductDetailPage({
}}
stockUnitOptions={stockUnitOptions}
productCategories={productCategories}
supplierOptions={supplierOptions}
inline
/>
</Card>
Expand Down Expand Up @@ -469,9 +475,18 @@ export default async function ProductDetailPage({
)}

{/* Suppliers */}
{suppliers.length > 0 && (
{(suppliers.length > 0 || product.preferredSupplierName) && (
<Card className="p-4">
<h2 className="text-sm font-semibold mb-3">Suppliers</h2>
{product.preferredSupplierName && (
<div className="mb-3 rounded-md border border-border bg-muted/40 p-2 text-sm">
<div className="text-xs text-muted-foreground">Preferred Supplier</div>
<div className="font-medium">
{product.preferredSupplierName}
{product.preferredSupplierLocked && <span className="ml-1 text-xs text-muted-foreground">(locked)</span>}
</div>
</div>
)}
<div className="space-y-3">
{suppliers.map((s) => (
<div key={s.supplierId} className="text-sm space-y-0.5">
Expand Down
5 changes: 4 additions & 1 deletion app/(dashboard)/inventory/inventory-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@ import { importProductsCsv } from '@/app/actions/import'

type VariableProduct = { id: string; sku: string; name: string }
type ProductCategoryOption = { id: string; name: string; parentId: string | null }
type SupplierOption = { id: string; name: string }

type Props = {
total: number
variableProducts: VariableProduct[]
stockUnitOptions: string[]
productCategories: ProductCategoryOption[]
supplierOptions: SupplierOption[]
}

export function InventoryHeader({ total, variableProducts, stockUnitOptions, productCategories }: Props) {
export function InventoryHeader({ total, variableProducts, stockUnitOptions, productCategories, supplierOptions }: Props) {
const router = useRouter()
const [showCreate, setShowCreate] = useState(false)

Expand Down Expand Up @@ -89,6 +91,7 @@ export function InventoryHeader({ total, variableProducts, stockUnitOptions, pro
variableProducts={variableProducts}
stockUnitOptions={stockUnitOptions}
productCategories={productCategories}
supplierOptions={supplierOptions}
onClose={() => setShowCreate(false)}
title="New Product"
/>
Expand Down
13 changes: 9 additions & 4 deletions app/(dashboard)/inventory/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Metadata } from 'next'
import { listProductCategories, listProducts, getVariableProducts } from '@/app/actions/products'
import { listProductCategories, listProductSupplierOptions, listProducts, getVariableProducts } from '@/app/actions/products'
import type { SortField, SortDir } from '@/app/actions/products'
import { getStockUnitOptions } from '@/app/actions/settings'
import { ProductFilters } from './product-filters'
Expand All @@ -14,6 +14,7 @@ type SearchParams = {
type?: string
lifecycleStatus?: string
categoryId?: string
supplierId?: string
page?: string
sort?: string
dir?: string
Expand All @@ -27,39 +28,43 @@ export default async function InventoryPage({
const sp = await searchParams
const page = parseInt(sp.page ?? '1')

const [result, variableProducts, stockUnitOptions, categories] = await Promise.all([
const [result, variableProducts, stockUnitOptions, categories, supplierOptions] = await Promise.all([
listProducts({
search: sp.search,
type: sp.type as ProductType | 'ALL' | undefined,
lifecycleStatus: sp.lifecycleStatus as ProductLifecycleStatus | 'ALL' | undefined,
categoryId: sp.categoryId,
supplierId: sp.supplierId,
page,
sort: (sp.sort as SortField) || undefined,
dir: (sp.dir as SortDir) || undefined,
}),
getVariableProducts(),
getStockUnitOptions(),
listProductCategories(),
listProductSupplierOptions(),
])

return (
<div className="space-y-4">
<InventoryHeader total={result.total} variableProducts={variableProducts} stockUnitOptions={stockUnitOptions} productCategories={categories} />
<InventoryHeader total={result.total} variableProducts={variableProducts} stockUnitOptions={stockUnitOptions} productCategories={categories} supplierOptions={supplierOptions} />

<ProductFilters
search={sp.search}
type={sp.type}
lifecycleStatus={sp.lifecycleStatus ?? 'ALL'}
categoryId={sp.categoryId}
productCategories={categories}
supplierId={sp.supplierId}
supplierOptions={supplierOptions}
/>

<ProductTable
products={result.products}
total={result.total}
page={result.page}
pageSize={result.pageSize}
searchParams={{ search: sp.search, type: sp.type, lifecycleStatus: sp.lifecycleStatus, categoryId: sp.categoryId, sort: sp.sort, dir: sp.dir }}
searchParams={{ search: sp.search, type: sp.type, lifecycleStatus: sp.lifecycleStatus, categoryId: sp.categoryId, supplierId: sp.supplierId, sort: sp.sort, dir: sp.dir }}
/>
</div>
)
Expand Down
25 changes: 22 additions & 3 deletions app/(dashboard)/inventory/product-filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ type Props = {
type?: string
lifecycleStatus?: string
categoryId?: string
supplierId?: string
productCategories: { id: string; name: string; parentId: string | null }[]
supplierOptions: { id: string; name: string }[]
}

export function ProductFilters({ search, type, lifecycleStatus, categoryId, productCategories }: Props) {
export function ProductFilters({ search, type, lifecycleStatus, categoryId, supplierId, productCategories, supplierOptions }: Props) {
const router = useRouter()
const pathname = usePathname()
const [isPending, startTransition] = useTransition()
Expand Down Expand Up @@ -55,13 +57,14 @@ export function ProductFilters({ search, type, lifecycleStatus, categoryId, prod
if (key !== 'type' && type) params.set('type', type)
if (key !== 'lifecycleStatus' && lifecycleStatus && lifecycleStatus !== 'ALL') params.set('lifecycleStatus', lifecycleStatus)
if (key !== 'categoryId' && categoryId) params.set('categoryId', categoryId)
if (key !== 'supplierId' && supplierId) params.set('supplierId', supplierId)
if (value) params.set(key, value)
// reset page on filter change
startTransition(() => {
router.push(`${pathname}?${params.toString()}`)
})
},
[router, pathname, search, type, lifecycleStatus, categoryId]
[router, pathname, search, type, lifecycleStatus, categoryId, supplierId]
)

useEffect(() => {
Expand Down Expand Up @@ -119,8 +122,9 @@ export function ProductFilters({ search, type, lifecycleStatus, categoryId, prod
onChange={(e) => update('lifecycleStatus', e.target.value === 'ALL' ? '' : e.target.value)}
>
<option value="ALL">All Status</option>
<option value="DRAFT">Draft</option>
<option value="ACTIVE">Active</option>
<option value="NOT_FOR_SALE">Not for sale</option>
<option value="EOL">End of life</option>
<option value="ARCHIVED">Archived</option>
</Select>
</div>
Expand All @@ -141,6 +145,21 @@ export function ProductFilters({ search, type, lifecycleStatus, categoryId, prod
</Select>
</div>

<div className="w-full sm:w-48">
<label htmlFor="inventory-supplier" className="sr-only">Filter by preferred supplier</label>
<Select
id="inventory-supplier"
className="w-full"
value={supplierId ?? ''}
onChange={(e) => update('supplierId', e.target.value)}
>
<option value="">All Suppliers</option>
{supplierOptions.map((supplier) => (
<option key={supplier.id} value={supplier.id}>{supplier.name}</option>
))}
</Select>
</div>

<div className="hidden sm:block">
<DropdownMenu>
<DropdownMenuTrigger
Expand Down
2 changes: 1 addition & 1 deletion app/(dashboard)/purchase-orders/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export default async function PurchaseOrderDetailPage({ params }: Props) {
try { if (carriersJson) carriers = JSON.parse(carriersJson) } catch { /* empty */ }

const products = productsResult.products.filter(
(p) => !['VARIABLE', 'NON_INVENTORY', 'KIT'].includes(p.type),
(p) => !['VARIABLE', 'NON_INVENTORY', 'KIT'].includes(p.type) && (p.lifecycleStatus === 'ACTIVE' || p.lifecycleStatus === 'DRAFT'),
)

return (
Expand Down
2 changes: 1 addition & 1 deletion app/(dashboard)/purchase-orders/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default async function PurchaseOrdersPage() {
])

const products = productsResult.products.filter(
(p) => !['VARIABLE', 'NON_INVENTORY', 'KIT'].includes(p.type),
(p) => !['VARIABLE', 'NON_INVENTORY', 'KIT'].includes(p.type) && (p.lifecycleStatus === 'ACTIVE' || p.lifecycleStatus === 'DRAFT'),
)

return (
Expand Down
Loading
Loading