bulk request
This commit is contained in:
181
src/features/store-user/BulkRequestDialog.tsx
Normal file
181
src/features/store-user/BulkRequestDialog.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Minus, Plus, X } from 'lucide-react';
|
||||
import { errorMessage } from '@/api/client';
|
||||
import { stockApi } from '@/api/stock';
|
||||
import type { Product } from '@/api/types';
|
||||
import { Drawer } from '@/features/store-admin/Drawer';
|
||||
import { queryKeys } from '@/queries/keys';
|
||||
|
||||
/**
|
||||
* Asking for several products at once.
|
||||
*
|
||||
* Restocking after a delivery is one errand. Doing it a product at a time means
|
||||
* a dialog per line, and the branch either gives up halfway or raises twenty
|
||||
* separate requests the admin then has to decide twenty times.
|
||||
*
|
||||
* ── Why the quantities are still per product ────────────────────────────────
|
||||
*
|
||||
* The obvious shortcut is one quantity for the whole batch. It is wrong: a shop
|
||||
* asks for two crates of oil and twenty packets of biscuits, and a single box
|
||||
* would either be ignored or produce a request nobody meant. So every line
|
||||
* keeps its own number, defaulted to 1 and adjustable in place.
|
||||
*
|
||||
* A line can be dropped here too. Picking eight and realising one was a mistake
|
||||
* should not mean closing this and starting the selection again.
|
||||
*/
|
||||
export interface BulkRequestDialogProps {
|
||||
products: Product[];
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
locationname: string;
|
||||
onClose: () => void;
|
||||
/** Called when at least one request was raised, so the caller can clear its selection. */
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function BulkRequestDialog({
|
||||
products,
|
||||
tenantid,
|
||||
locationid,
|
||||
locationname,
|
||||
onClose,
|
||||
onDone,
|
||||
}: BulkRequestDialogProps) {
|
||||
const client = useQueryClient();
|
||||
const [qty, setQty] = useState<Record<number, number>>(() =>
|
||||
Object.fromEntries(products.map((product) => [product.productid, 1])),
|
||||
);
|
||||
const [dropped, setDropped] = useState<ReadonlySet<number>>(() => new Set());
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
const lines = products.filter((product) => !dropped.has(product.productid));
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: () =>
|
||||
stockApi.createMany(
|
||||
lines.map((product) => ({
|
||||
tenantid,
|
||||
locationid,
|
||||
productid: product.productid,
|
||||
qty: qty[product.productid] ?? 1,
|
||||
locationname,
|
||||
productname: product.productname ?? '',
|
||||
})),
|
||||
),
|
||||
onSuccess: async (result) => {
|
||||
const failed = result?.failed?.length ?? 0;
|
||||
await client.invalidateQueries({ queryKey: queryKeys.stock.all });
|
||||
if (failed > 0) {
|
||||
// Some landed. Saying so beats a bare success that hides the gap, and
|
||||
// beats an error that implies none of them were raised.
|
||||
setProblem(
|
||||
`${lines.length - failed} of ${lines.length} raised. The rest could not be — try them again.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
onDone();
|
||||
onClose();
|
||||
},
|
||||
onError: (cause) => setProblem(errorMessage(cause)),
|
||||
});
|
||||
|
||||
const setLine = (productid: number, next: number) =>
|
||||
setQty((prev) => ({ ...prev, [productid]: Math.max(1, Math.min(9999, next)) }));
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="Ask for stock"
|
||||
subtitle={`${lines.length} product${lines.length === 1 ? '' : 's'}`}
|
||||
width={520}
|
||||
onClose={onClose}
|
||||
>
|
||||
<VStack gap={3}>
|
||||
<Text type="body" size="sm" color="secondary">
|
||||
Set how much of each you need. Your admin decides each one, and nothing reaches the shelf
|
||||
until they approve it.
|
||||
</Text>
|
||||
|
||||
<div className="bulkreq-list">
|
||||
{lines.map((product) => (
|
||||
<div key={product.productid} className="bulkreq-line">
|
||||
<VStack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text type="label" size="sm" weight="semibold" maxLines={1}>
|
||||
{product.productname ?? `Product ${product.productid}`}
|
||||
</Text>
|
||||
<Text type="body" size="xsm" color="secondary">
|
||||
{[product.unitvalue, product.productunit].filter(Boolean).join(' ') || '—'}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<HStack gap={0.5} align="center">
|
||||
<button
|
||||
type="button"
|
||||
className="qty-step"
|
||||
aria-label={`One fewer ${product.productname ?? 'unit'}`}
|
||||
onClick={() => setLine(product.productid, (qty[product.productid] ?? 1) - 1)}
|
||||
>
|
||||
<Minus size={13} />
|
||||
</button>
|
||||
<input
|
||||
className="qty-input"
|
||||
inputMode="numeric"
|
||||
value={String(qty[product.productid] ?? 1)}
|
||||
aria-label={`Quantity of ${product.productname ?? 'product'}`}
|
||||
onChange={(event) =>
|
||||
setLine(product.productid, Number(event.target.value.replace(/\D/g, '')) || 1)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="qty-step"
|
||||
aria-label={`One more ${product.productname ?? 'unit'}`}
|
||||
onClick={() => setLine(product.productid, (qty[product.productid] ?? 1) + 1)}
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="qty-step"
|
||||
aria-label={`Remove ${product.productname ?? 'product'} from this request`}
|
||||
onClick={() =>
|
||||
setDropped((prev) => new Set(prev).add(product.productid))
|
||||
}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</HStack>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{problem ? (
|
||||
<Card padding={2} variant="transparent">
|
||||
<Text type="body" size="sm" role="alert" style={{ color: 'var(--color-error, #d64545)' }}>
|
||||
{problem}
|
||||
</Text>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<HStack justify="between" align="center" gap={2} wrap="wrap">
|
||||
<Button label="Cancel" variant="ghost" isDisabled={send.isPending} onClick={onClose} />
|
||||
<Button
|
||||
label={send.isPending ? 'Sending…' : `Send ${lines.length} request${lines.length === 1 ? '' : 's'}`}
|
||||
variant="primary"
|
||||
isLoading={send.isPending}
|
||||
isDisabled={send.isPending || lines.length === 0}
|
||||
onClick={() => {
|
||||
setProblem(null);
|
||||
send.mutate();
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user