enh
This commit is contained in:
parent
f8e7eec754
commit
ee60799a8b
8 changed files with 118 additions and 1 deletions
|
|
@ -6,6 +6,7 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
import { AppContainer, MyText, tw, MyButton, useManualRefresh, SearchBar, useMarkDataFetchers, MyFlatList } from 'common-ui';
|
import { AppContainer, MyText, tw, MyButton, useManualRefresh, SearchBar, useMarkDataFetchers, MyFlatList } from 'common-ui';
|
||||||
|
|
||||||
import { trpc } from '@/src/trpc-client';
|
import { trpc } from '@/src/trpc-client';
|
||||||
|
import { SuccessToast, ErrorToast } from '@/services/toaster';
|
||||||
import type { AdminSku } from '@packages/shared';
|
import type { AdminSku } from '@packages/shared';
|
||||||
|
|
||||||
type FilterType = 'all' | 'in-stock' | 'out-of-stock';
|
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 { 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);
|
useManualRefresh(refetch);
|
||||||
|
|
||||||
useMarkDataFetchers(() => {
|
useMarkDataFetchers(() => {
|
||||||
|
|
@ -61,6 +72,10 @@ export default function Products() {
|
||||||
router.push(`/(drawer)/dashboard/products/detail/${productId}` as any);
|
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 }) => (
|
const FilterButton = ({ filter, label, count }: { filter: FilterType; label: string; count: number }) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => setActiveFilter(filter)}
|
onPress={() => setActiveFilter(filter)}
|
||||||
|
|
@ -193,6 +208,17 @@ export default function Products() {
|
||||||
<MaterialIcons name="edit" size={16} color="white" />
|
<MaterialIcons name="edit" size={16} color="white" />
|
||||||
<MyText style={tw`text-white font-semibold ml-1`}>Edit</MyText>
|
<MyText style={tw`text-white font-semibold ml-1`}>Edit</MyText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => 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' : ''}`}
|
||||||
|
>
|
||||||
|
<MaterialIcons name={isOut ? 'check-circle' : 'block'} size={16} color="white" />
|
||||||
|
<MyText style={tw`text-white font-semibold ml-1`}>
|
||||||
|
{isOut ? 'Stock' : 'Out'}
|
||||||
|
</MyText>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import React, { useState, useMemo } from 'react'
|
import React, { useState, useMemo } from 'react'
|
||||||
import { createFileRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'
|
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 { AppContainer, p as MyText, MyButton, SearchBar, MyFlatList } from 'web-components'
|
||||||
import { trpc } from '@/lib/trpc-client'
|
import { trpc } from '@/lib/trpc-client'
|
||||||
import { useManualRefresh, useMarkDataFetchers } from '@/lib/refresh-context'
|
import { useManualRefresh, useMarkDataFetchers } from '@/lib/refresh-context'
|
||||||
|
import { SuccessToast, ErrorToast } from '@/services/toaster'
|
||||||
import type { AdminSku } from '@packages/shared'
|
import type { AdminSku } from '@packages/shared'
|
||||||
|
|
||||||
export const Route = createFileRoute('/dashboard/products')({
|
export const Route = createFileRoute('/dashboard/products')({
|
||||||
|
|
@ -39,6 +40,16 @@ function Products() {
|
||||||
|
|
||||||
const { data: productsData, isLoading, error, refetch } = trpc.admin.product.getProducts.useQuery()
|
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)
|
useManualRefresh(refetch)
|
||||||
|
|
||||||
useMarkDataFetchers(() => {
|
useMarkDataFetchers(() => {
|
||||||
|
|
@ -70,6 +81,10 @@ function Products() {
|
||||||
navigate({ to: '/dashboard/products/$id', params: { id: String(productId) } })
|
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.
|
// Render child routes (new / edit / detail) when not on the list route.
|
||||||
// Placed after all hooks so the hook order stays stable across renders.
|
// Placed after all hooks so the hook order stays stable across renders.
|
||||||
if (location.pathname !== '/dashboard/products') {
|
if (location.pathname !== '/dashboard/products') {
|
||||||
|
|
@ -191,6 +206,17 @@ function Products() {
|
||||||
<Pencil size={16} color="white" />
|
<Pencil size={16} color="white" />
|
||||||
<MyText className="ml-1 font-semibold text-white">Edit</MyText>
|
<MyText className="ml-1 font-semibold text-white">Edit</MyText>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleStock(product)}
|
||||||
|
disabled={toggleOutOfStock.isPending && toggleOutOfStock.variables?.id === product.id}
|
||||||
|
className={`flex flex-1 flex-row items-center justify-center rounded-lg p-3 ${
|
||||||
|
isOut ? 'bg-green-500' : 'bg-orange-500'
|
||||||
|
} ${toggleOutOfStock.isPending && toggleOutOfStock.variables?.id === product.id ? 'opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
{isOut ? <CheckCircle2 size={16} color="white" /> : <Ban size={16} color="white" />}
|
||||||
|
<MyText className="ml-1 font-semibold text-white">{isOut ? 'Stock' : 'Out'}</MyText>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ export {
|
||||||
updateProductGroup,
|
updateProductGroup,
|
||||||
deleteProductGroup,
|
deleteProductGroup,
|
||||||
updateProductPrices,
|
updateProductPrices,
|
||||||
|
toggleProductOutOfStock,
|
||||||
// Merge duplicate products into multi-SKU products
|
// Merge duplicate products into multi-SKU products
|
||||||
mergeDuplicateProducts,
|
mergeDuplicateProducts,
|
||||||
// Admin - Slots
|
// Admin - Slots
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
updateProductGroup as updateProductGroupInDb,
|
updateProductGroup as updateProductGroupInDb,
|
||||||
deleteProductGroup as deleteProductGroupInDb,
|
deleteProductGroup as deleteProductGroupInDb,
|
||||||
updateProductPrices as updateProductPricesInDb,
|
updateProductPrices as updateProductPricesInDb,
|
||||||
|
toggleProductOutOfStock as toggleProductOutOfStockInDb,
|
||||||
checkProductExistsByName,
|
checkProductExistsByName,
|
||||||
checkUnitExists,
|
checkUnitExists,
|
||||||
createProduct as createProductInDb,
|
createProduct as createProductInDb,
|
||||||
|
|
@ -46,6 +47,7 @@ import type {
|
||||||
AdminUpdateSlotProductsResult,
|
AdminUpdateSlotProductsResult,
|
||||||
AdminSlotsProductIdsResult,
|
AdminSlotsProductIdsResult,
|
||||||
AdminUpdateProductPricesResult,
|
AdminUpdateProductPricesResult,
|
||||||
|
AdminToggleOutOfStockResult,
|
||||||
} from '@packages/shared'
|
} from '@packages/shared'
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -178,6 +180,29 @@ export const productRouter = router({
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
toggleOutOfStock: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
id: z.number(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input }): Promise<AdminToggleOutOfStockResult> => {
|
||||||
|
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
|
createProduct: protectedProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
name: z.string().min(1, 'Name is required'),
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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 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.
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ export {
|
||||||
updateProductGroup,
|
updateProductGroup,
|
||||||
deleteProductGroup,
|
deleteProductGroup,
|
||||||
updateProductPrices,
|
updateProductPrices,
|
||||||
|
toggleProductOutOfStock,
|
||||||
} from './src/admin-apis/product'
|
} from './src/admin-apis/product'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
|
||||||
|
|
@ -1036,6 +1036,29 @@ export async function updateProductPrices(updates: Array<{
|
||||||
return { updatedCount: updates.length, invalidIds: [] }
|
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
|
// Product Helpers for Admin Controller
|
||||||
|
|
|
||||||
|
|
@ -429,6 +429,12 @@ export interface AdminDeleteProductResult {
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminToggleOutOfStockResult {
|
||||||
|
productId: number;
|
||||||
|
isOutOfStock: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminUpdateSlotProductsResult {
|
export interface AdminUpdateSlotProductsResult {
|
||||||
message: string;
|
message: string;
|
||||||
added: number;
|
added: number;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue