toggle update

This commit is contained in:
2026-10-06 10:34:48 +05:30
parent b7f9a6aaeb
commit a764df225f
2 changed files with 68 additions and 23 deletions

View File

@@ -31,27 +31,30 @@ import './pages/deliveries.css';
export function HealthScorePanel({ export function HealthScorePanel({
product, product,
category, category,
onToggleShown,
showToggle, showToggle,
onScoreResolved, onScoreResolved,
}: { }: {
product: Product; product: Product;
/** /**
* Turn the score on or off for a product ALREADY on the shelf. Omitted where * The show/hide control, for both moments it appears in: a product being
* the viewer does not get to decide — the Nearle staff console passes * imported, where the answer is carried into the import, and one already on
* nothing, because the decision being made there is whether the data is fit * the shelf, where each click is a write.
* to publish at all, not whether one shopkeeper wants it on their shelf.
*/
onToggleShown?: (show: boolean) => void;
/**
* The same control, for a product being imported — where the answer is not
* saved on each click but carried into the import.
* *
* Separate from `onToggleShown` because the two are different moments with * One prop rather than two, because the difference is entirely the caller's —
* different state: one writes immediately, the other is a form field until * the panel renders the same switch either way, and two props meant two code
* the import button is pressed. * paths that could drift in what they looked like.
*
* Omitted where the viewer does not get to decide: the Nearle staff console
* passes nothing, and so does a branch user, since this writes per tenant.
*/ */
showToggle?: { isShown: boolean; onChange: (show: boolean) => void }; showToggle?: {
isShown: boolean;
onChange: (show: boolean) => void;
/** Shown beneath the switch. A write that fails silently is the bug this
panel already shipped once. */
error?: string | undefined;
isBusy?: boolean | undefined;
};
/** /**
* Reports whether this product has a score at all. * Reports whether this product has a score at all.
* *
@@ -188,9 +191,7 @@ export function HealthScorePanel({
<Note> <Note>
Hidden from customers for this product. The nutrition figures are still shown. Hidden from customers for this product. The nutrition figures are still shown.
</Note> </Note>
{onToggleShown ? ( {showToggle ? <ShowScoreToggle {...showToggle} /> : null}
<ShowScoreToggle isShown={false} onChange={onToggleShown} />
) : null}
</Section> </Section>
); );
} }
@@ -313,11 +314,7 @@ export function HealthScorePanel({
{/* Beneath the score, deliberately. The decision is about the thing above {/* Beneath the score, deliberately. The decision is about the thing above
it, and a shopkeeper weighing "is this rating fair to my product" it, and a shopkeeper weighing "is this rating fair to my product"
should be reading the rating and the confidence while they decide. */} should be reading the rating and the confidence while they decide. */}
{showToggle ? ( {showToggle ? <ShowScoreToggle {...showToggle} /> : null}
<ShowScoreToggle isShown={showToggle.isShown} onChange={showToggle.onChange} />
) : onToggleShown ? (
<ShowScoreToggle isShown onChange={onToggleShown} />
) : null}
</Section> </Section>
); );
} }
@@ -338,9 +335,13 @@ export function HealthScorePanel({
function ShowScoreToggle({ function ShowScoreToggle({
isShown, isShown,
onChange, onChange,
error,
isBusy,
}: { }: {
isShown: boolean; isShown: boolean;
onChange: (show: boolean) => void; onChange: (show: boolean) => void;
error?: string | undefined;
isBusy?: boolean | undefined;
}) { }) {
return ( return (
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
@@ -349,9 +350,18 @@ function ShowScoreToggle({
description="Appears on this product in your catalogue and in the customer app. The nutrition figures are shown either way." description="Appears on this product in your catalogue and in the customer app. The nutrition figures are shown either way."
value={isShown} value={isShown}
onChange={onChange} onChange={onChange}
isLoading={Boolean(isBusy)}
size="sm" size="sm"
labelSpacing="spread" labelSpacing="spread"
/> />
{/* A write that fails has to say so. This one shipped without it: the
switch moved, nothing saved, and the only way to find out was to call
the API by hand. */}
{error ? (
<p style={{ margin: '6px 0 0', fontSize: 11.5, color: 'var(--color-error, #d64545)' }}>
{error}
</p>
) : null}
</div> </div>
); );
} }

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { BookOpen, ClipboardList, EyeOff, Package, Ruler, Tag } from 'lucide-react'; import { BookOpen, ClipboardList, EyeOff, Package, Ruler, Tag } from 'lucide-react';
import { errorMessage } from '@/api/client';
import { productsApi } from '@/api/products'; import { productsApi } from '@/api/products';
import type { CatalogueProduct, Product, StockRequest } from '@/api/types'; import type { CatalogueProduct, Product, StockRequest } from '@/api/types';
/* A pure reading of a `StockRequest`, not a Store user component — the same /* A pure reading of a `StockRequest`, not a Store user component — the same
@@ -141,9 +142,36 @@ export function ProductDrawer({
stale would mean the drawer and the line behind it disagreeing about what a stale would mean the drawer and the line behind it disagreeing about what a
customer sees. customer sees.
*/ */
/*
What this drawer believes the switch is set to.
Local, because `product` is a prop captured when the drawer opened and does
not change when the list behind it refetches — so without this the switch
read the same value before and after a click, and a save that worked looked
exactly like one that did not. It shipped that way once.
Seeded from the record and reconciled on failure, never on success: the
server has agreed by then, and snapping back to a stale prop would undo what
the shopkeeper just did on screen.
*/
const [scoreShown, setScoreShown] = useState(product.showhealthscore !== false);
const [scoreError, setScoreError] = useState<string | null>(null);
const showScore = useMutation({ const showScore = useMutation({
mutationFn: (show: boolean) => mutationFn: (show: boolean) =>
productsApi.setShowHealthScore(tenantid as number, product.productid, show), productsApi.setShowHealthScore(tenantid as number, product.productid, show),
onMutate: (show: boolean) => {
setScoreError(null);
setScoreShown(show);
return { previous: !show };
},
onError: (error, _show, context) => {
// Put the switch back where it was and say why. A write that fails
// silently is worse than one that fails loudly: the shopkeeper walks away
// believing a rating is hidden from their customers when it is not.
if (context) setScoreShown(context.previous);
setScoreError(errorMessage(error));
},
onSuccess: () => { onSuccess: () => {
void client.invalidateQueries({ queryKey: queryKeys.products.all }); void client.invalidateQueries({ queryKey: queryKeys.products.all });
}, },
@@ -271,7 +299,14 @@ export function ProductDrawer({
product={product} product={product}
category={product.categoryname || product.Subcategoryname} category={product.categoryname || product.Subcategoryname}
{...(canManage && product.tenantid && product.productid {...(canManage && product.tenantid && product.productid
? { onToggleShown: (show: boolean) => showScore.mutate(show) } ? {
showToggle: {
isShown: scoreShown,
onChange: (show: boolean) => showScore.mutate(show),
error: scoreError ?? undefined,
isBusy: showScore.isPending,
},
}
: {})} : {})}
/> />