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;