diff --git a/apps/admin-ui/app/(drawer)/dashboard/products/index.tsx b/apps/admin-ui/app/(drawer)/dashboard/products/index.tsx
index f582282..cae5b19 100644
--- a/apps/admin-ui/app/(drawer)/dashboard/products/index.tsx
+++ b/apps/admin-ui/app/(drawer)/dashboard/products/index.tsx
@@ -6,6 +6,7 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { AppContainer, MyText, tw, MyButton, useManualRefresh, SearchBar, useMarkDataFetchers, MyFlatList } from 'common-ui';
import { trpc } from '@/src/trpc-client';
+import { SuccessToast, ErrorToast } from '@/services/toaster';
import type { AdminSku } from '@packages/shared';
type FilterType = 'all' | 'in-stock' | 'out-of-stock';
@@ -24,6 +25,16 @@ export default function Products() {
const { data: productsData, isLoading, error, refetch } = trpc.admin.product.getProducts.useQuery();
+ const toggleOutOfStock = trpc.admin.product.toggleOutOfStock.useMutation({
+ onSuccess: async (res) => {
+ await refetch();
+ SuccessToast(res.message);
+ },
+ onError: (err) => {
+ ErrorToast(err.message || 'Failed to update stock status');
+ },
+ });
+
useManualRefresh(refetch);
useMarkDataFetchers(() => {
@@ -61,6 +72,10 @@ export default function Products() {
router.push(`/(drawer)/dashboard/products/detail/${productId}` as any);
};
+ const handleToggleStock = (product: { id: number }) => {
+ toggleOutOfStock.mutate({ id: product.id });
+ };
+
const FilterButton = ({ filter, label, count }: { filter: FilterType; label: string; count: number }) => (
setActiveFilter(filter)}
@@ -193,6 +208,17 @@ export default function Products() {
Edit
+
+ handleToggleStock(product)}
+ disabled={toggleOutOfStock.isPending && toggleOutOfStock.variables?.id === product.id}
+ style={tw`flex-1 ${isOut ? 'bg-green-500' : 'bg-orange-500'} p-3 rounded-lg flex-row items-center justify-center ${toggleOutOfStock.isPending && toggleOutOfStock.variables?.id === product.id ? 'opacity-50' : ''}`}
+ >
+
+
+ {isOut ? 'Stock' : 'Out'}
+
+
diff --git a/apps/admin-web/src/routes/dashboard.products.tsx b/apps/admin-web/src/routes/dashboard.products.tsx
index 77743d3..9658585 100644
--- a/apps/admin-web/src/routes/dashboard.products.tsx
+++ b/apps/admin-web/src/routes/dashboard.products.tsx
@@ -1,9 +1,10 @@
import React, { useState, useMemo } from 'react'
import { createFileRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'
-import { Image as ImageIcon, Eye, Pencil, Package } from 'lucide-react'
+import { Image as ImageIcon, Eye, Pencil, Package, CheckCircle2, Ban } from 'lucide-react'
import { AppContainer, p as MyText, MyButton, SearchBar, MyFlatList } from 'web-components'
import { trpc } from '@/lib/trpc-client'
import { useManualRefresh, useMarkDataFetchers } from '@/lib/refresh-context'
+import { SuccessToast, ErrorToast } from '@/services/toaster'
import type { AdminSku } from '@packages/shared'
export const Route = createFileRoute('/dashboard/products')({
@@ -39,6 +40,16 @@ function Products() {
const { data: productsData, isLoading, error, refetch } = trpc.admin.product.getProducts.useQuery()
+ const toggleOutOfStock = trpc.admin.product.toggleOutOfStock.useMutation({
+ onSuccess: async (res) => {
+ await refetch()
+ SuccessToast(res.message)
+ },
+ onError: (err) => {
+ ErrorToast(err.message || 'Failed to update stock status')
+ },
+ })
+
useManualRefresh(refetch)
useMarkDataFetchers(() => {
@@ -70,6 +81,10 @@ function Products() {
navigate({ to: '/dashboard/products/$id', params: { id: String(productId) } })
}
+ const handleToggleStock = (product: { id: number }) => {
+ toggleOutOfStock.mutate({ id: product.id })
+ }
+
// Render child routes (new / edit / detail) when not on the list route.
// Placed after all hooks so the hook order stays stable across renders.
if (location.pathname !== '/dashboard/products') {
@@ -191,6 +206,17 @@ function Products() {
Edit
+
+
diff --git a/apps/backend/src/sqliteImporter.ts b/apps/backend/src/sqliteImporter.ts
index cb08260..f51650e 100644
--- a/apps/backend/src/sqliteImporter.ts
+++ b/apps/backend/src/sqliteImporter.ts
@@ -88,6 +88,7 @@ export {
updateProductGroup,
deleteProductGroup,
updateProductPrices,
+ toggleProductOutOfStock,
// Merge duplicate products into multi-SKU products
mergeDuplicateProducts,
// Admin - Slots
diff --git a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts
index e26083c..0ff6297 100644
--- a/apps/backend/src/trpc/apis/admin-apis/apis/product.ts
+++ b/apps/backend/src/trpc/apis/admin-apis/apis/product.ts
@@ -17,6 +17,7 @@ import {
updateProductGroup as updateProductGroupInDb,
deleteProductGroup as deleteProductGroupInDb,
updateProductPrices as updateProductPricesInDb,
+ toggleProductOutOfStock as toggleProductOutOfStockInDb,
checkProductExistsByName,
checkUnitExists,
createProduct as createProductInDb,
@@ -46,6 +47,7 @@ import type {
AdminUpdateSlotProductsResult,
AdminSlotsProductIdsResult,
AdminUpdateProductPricesResult,
+ AdminToggleOutOfStockResult,
} from '@packages/shared'
@@ -178,6 +180,29 @@ export const productRouter = router({
}
}),
+ toggleOutOfStock: protectedProcedure
+ .input(z.object({
+ id: z.number(),
+ }))
+ .mutation(async ({ input }): Promise => {
+ const { id } = input;
+
+ const result = await toggleProductOutOfStockInDb(id)
+
+ if (!result) {
+ throw new ApiError('Product not found', 404)
+ }
+
+ // Regenerate products/availability cache files so the user app reflects it.
+ await scheduleStoreInitialization()
+
+ return {
+ productId: result.id,
+ isOutOfStock: result.isOutOfStock,
+ message: `Product marked as ${result.isOutOfStock ? 'out of stock' : 'in stock'}`,
+ }
+ }),
+
createProduct: protectedProcedure
.input(z.object({
name: z.string().min(1, 'Name is required'),
diff --git a/change-log.txt b/change-log.txt
index c35b490..977c93d 100644
--- a/change-log.txt
+++ b/change-log.txt
@@ -1576,3 +1576,12 @@ ROOT CAUSE: user product `unitNotation` already contains the amount+unit (e.g. "
FIX (match user-ui): drop `formatQuantity` helpers + `productQuantity` prefixes; render `unitNotation` (with productType !== 'combo' guard where user-ui has one). Files: components/ProductCard.tsx, components/AddToCartDialog.tsx, components/FloatingCartBar.tsx, routes/home.product.$id.tsx, routes/flash.tsx, routes/slot-view.tsx, routes/cart.tsx, routes/home.cart.tsx.
[2026-09-06 04:05:00] VERIFIED web-ui quantity-label fix: grep shows zero formatQuantity/productQuantity render usages left; tsc 0 errors; vite build clean. Label now renders `unitNotation` directly (e.g. Qty: 0.5Kg, 1Kg) matching user-ui.
+
+[2026-09-06 05:00:00] Feature: product out-of-stock toggle on admin products index (admin-ui + admin-web).
+- packages/shared/types/admin.ts: add AdminToggleOutOfStockResult { productId, isOutOfStock, message }.
+- packages/db_helper_sqlite/src/admin-apis/product.ts: add toggleProductOutOfStock(productId) — flips isOutOfStock on ALL SKU rows' productMarketStats (next = !some(out)); returns {id,isOutOfStock}|null.
+- apps/backend/src/sqliteImporter.ts: export toggleProductOutOfStock.
+- apps/backend/.../admin-apis/apis/product.ts: add toggleOutOfStock procedure ({id} -> helper -> 404 -> scheduleStoreInitialization -> result). Caches (products.json + availability.json) regenerate.
+- apps/admin-ui .../products/index.tsx + apps/admin-web .../dashboard.products.tsx: third card button (green "Stock" when out, orange "Out" when live), mutation + refetch + toast.
+
+[2026-09-06 05:10:00] VERIFIED out-of-stock toggle: packages/db_helper_sqlite index.ts needed explicit export of toggleProductOutOfStock (added). tsc clean: sqlite 0, backend 0, admin-ui 0, admin-web 0; admin-web vite build clean. Note: admin-ui-only toaster + backend sqlite-only DB helper (used by running backend); postgres parity not added.
diff --git a/packages/db_helper_sqlite/index.ts b/packages/db_helper_sqlite/index.ts
index 29af47c..b8aa18a 100644
--- a/packages/db_helper_sqlite/index.ts
+++ b/packages/db_helper_sqlite/index.ts
@@ -99,6 +99,7 @@ export {
updateProductGroup,
deleteProductGroup,
updateProductPrices,
+ toggleProductOutOfStock,
} from './src/admin-apis/product'
export {
diff --git a/packages/db_helper_sqlite/src/admin-apis/product.ts b/packages/db_helper_sqlite/src/admin-apis/product.ts
index 4a7bcb4..027f2d3 100644
--- a/packages/db_helper_sqlite/src/admin-apis/product.ts
+++ b/packages/db_helper_sqlite/src/admin-apis/product.ts
@@ -1036,6 +1036,29 @@ export async function updateProductPrices(updates: Array<{
return { updatedCount: updates.length, invalidIds: [] }
}
+export async function toggleProductOutOfStock(
+ productId: number
+): Promise<{ id: number; isOutOfStock: boolean } | null> {
+ const skus = await db.query.productSkus.findMany({
+ where: eq(productSkus.productId, productId),
+ with: { marketStats: true },
+ })
+
+ if (skus.length === 0) {
+ return null
+ }
+
+ // Product is "out" if any of its SKUs is out; toggle the whole product.
+ const nextIsOutOfStock = !skus.some((sku) => sku.marketStats?.isOutOfStock)
+
+ await db
+ .update(productMarketStats)
+ .set({ isOutOfStock: nextIsOutOfStock })
+ .where(inArray(productMarketStats.skuId, skus.map((sku) => sku.id)))
+
+ return { id: productId, isOutOfStock: nextIsOutOfStock }
+}
+
// ==========================================================================
// Product Helpers for Admin Controller
diff --git a/packages/shared/types/admin.ts b/packages/shared/types/admin.ts
index b0ff93b..a698b8f 100644
--- a/packages/shared/types/admin.ts
+++ b/packages/shared/types/admin.ts
@@ -429,6 +429,12 @@ export interface AdminDeleteProductResult {
message: string;
}
+export interface AdminToggleOutOfStockResult {
+ productId: number;
+ isOutOfStock: boolean;
+ message: string;
+}
+
export interface AdminUpdateSlotProductsResult {
message: string;
added: number;