Browse Source

feat(dashboard): Add stock level controls for multiple locations

Michael Bromley 3 months ago
parent
commit
516b0ffee8

+ 56 - 0
packages/dashboard/src/app/routes/_authenticated/_product-variants/components/add-stock-location-dropdown.tsx

@@ -0,0 +1,56 @@
+import { useLingui } from '@lingui/react/macro';
+import { PlusIcon } from 'lucide-react';
+
+import { Button } from '@/vdb/components/ui/button';
+import {
+    DropdownMenu,
+    DropdownMenuContent,
+    DropdownMenuItem,
+    DropdownMenuTrigger,
+} from '@/vdb/components/ui/dropdown-menu';
+import { ResultOf } from 'gql.tada';
+
+import { stockLocationsQueryDocument } from '../product-variants.graphql.js';
+
+interface AddStockLocationDropdownProps {
+    availableStockLocations: ResultOf<typeof stockLocationsQueryDocument>['stockLocations']['items'];
+    usedStockLocationIds: string[];
+    onStockLocationSelect: (stockLocationId: string, stockLocationName: string) => void;
+    placeholder?: string;
+}
+
+export function AddStockLocationDropdown({
+    availableStockLocations,
+    usedStockLocationIds,
+    onStockLocationSelect,
+    placeholder,
+}: AddStockLocationDropdownProps) {
+    const { t } = useLingui();
+
+    const unusedStockLocations = availableStockLocations.filter(sl => !usedStockLocationIds.includes(sl.id));
+
+    if (unusedStockLocations.length === 0) {
+        return null;
+    }
+
+    return (
+        <DropdownMenu>
+            <DropdownMenuTrigger asChild>
+                <Button variant="outline" className="gap-2">
+                    <PlusIcon className="size-4" />
+                    {placeholder || t`Add stock level for another location`}
+                </Button>
+            </DropdownMenuTrigger>
+            <DropdownMenuContent>
+                {unusedStockLocations.map(location => (
+                    <DropdownMenuItem
+                        key={location.id}
+                        onSelect={() => onStockLocationSelect(location.id, location.name)}
+                    >
+                        {location.name}
+                    </DropdownMenuItem>
+                ))}
+            </DropdownMenuContent>
+        </DropdownMenu>
+    );
+}

+ 12 - 0
packages/dashboard/src/app/routes/_authenticated/_product-variants/product-variants.graphql.ts

@@ -193,3 +193,15 @@ export const updateProductVariantsDocument = graphql(`
         }
     }
 `);
+
+export const stockLocationsQueryDocument = graphql(`
+    query StockLocations {
+        stockLocations(options: { take: 100 }) {
+            items {
+                id
+                name
+                description
+            }
+        }
+    }
+`);

+ 74 - 31
packages/dashboard/src/app/routes/_authenticated/_product-variants/product-variants_.$id.tsx

@@ -24,19 +24,21 @@ import {
 } from '@/vdb/framework/layout-engine/page-layout.js';
 import { detailPageRouteLoader } from '@/vdb/framework/page/detail-page-route-loader.js';
 import { useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
+import { api } from '@/vdb/graphql/api.js';
 import { useChannel } from '@/vdb/hooks/use-channel.js';
-import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
 import { Trans, useLingui } from '@lingui/react/macro';
+import { useQuery } from '@tanstack/react-query';
 import { createFileRoute, useNavigate } from '@tanstack/react-router';
 import { VariablesOf } from 'gql.tada';
 import { Trash } from 'lucide-react';
-import { Fragment } from 'react/jsx-runtime';
 import { toast } from 'sonner';
 import { AddCurrencyDropdown } from './components/add-currency-dropdown.js';
+import { AddStockLocationDropdown } from './components/add-stock-location-dropdown.js';
 import { VariantPriceDetail } from './components/variant-price-detail.js';
 import {
     createProductVariantDocument,
     productVariantDetailDocument,
+    stockLocationsQueryDocument,
     updateProductVariantDocument,
 } from './product-variants.graphql.js';
 
@@ -69,7 +71,11 @@ function ProductVariantDetailPage() {
     const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const { t } = useLingui();
     const { activeChannel } = useChannel();
-    const { formatCurrencyName } = useLocalFormat();
+
+    const { data: stockLocationsData } = useQuery({
+        queryKey: ['stockLocations'],
+        queryFn: () => api.query(stockLocationsQueryDocument, {}),
+    });
 
     const { form, submitHandler, entity, isPending, resetForm } = useDetailPage({
         pageId,
@@ -125,7 +131,7 @@ function ProductVariantDetailPage() {
     });
 
     const availableCurrencies = activeChannel?.availableCurrencyCodes ?? [];
-    const [prices, taxCategoryId] = form.watch(['prices', 'taxCategoryId']);
+    const [prices, taxCategoryId, stockLevels] = form.watch(['prices', 'taxCategoryId', 'stockLevels']);
 
     // Filter out deleted prices for display
     const activePrices = prices?.filter(p => !p.delete) ?? [];
@@ -134,6 +140,9 @@ function ProductVariantDetailPage() {
     const usedCurrencies = activePrices.map(p => p.currencyCode);
     const unusedCurrencies = availableCurrencies.filter(c => !usedCurrencies.includes(c));
 
+    // Get used stock location IDs
+    const usedStockLocationIds = stockLevels?.map(sl => sl.stockLocationId) ?? [];
+
     const handleAddCurrency = (currencyCode: string) => {
         const currentPrices = form.getValues('prices') || [];
 
@@ -178,6 +187,18 @@ function ProductVariantDetailPage() {
         });
     };
 
+    const handleAddStockLocation = (stockLocationId: string, stockLocationName: string) => {
+        const currentStockLevels = form.getValues('stockLevels') || [];
+        const newStockLevel = {
+            stockLocationId,
+            stockOnHand: 0,
+        };
+        form.setValue('stockLevels', [...currentStockLevels, newStockLevel], {
+            shouldDirty: true,
+            shouldValidate: true,
+        });
+    };
+
     return (
         <Page pageId={pageId} form={form} submitHandler={submitHandler} entity={entity}>
             <PageTitle>
@@ -292,33 +313,6 @@ function ProductVariantDetailPage() {
                 </PageBlock>
                 <PageBlock column="main" blockId="stock" title={<Trans>Stock</Trans>}>
                     <DetailFormGrid>
-                        {entity?.stockLevels.map((stockLevel, index) => (
-                            <Fragment key={stockLevel.id}>
-                                <FormFieldWrapper
-                                    control={form.control}
-                                    name={`stockLevels.${index}.stockOnHand`}
-                                    label={<Trans>Stock level</Trans>}
-                                    render={({ field }) => (
-                                        <Input
-                                            type="number"
-                                            value={field.value}
-                                            onChange={e => {
-                                                field.onChange(e.target.valueAsNumber);
-                                            }}
-                                        />
-                                    )}
-                                />
-                                <div>
-                                    <FormItem>
-                                        <FormLabel>
-                                            <Trans>Allocated</Trans>
-                                        </FormLabel>
-                                        <div className="text-sm pt-1.5">{stockLevel.stockAllocated}</div>
-                                    </FormItem>
-                                </div>
-                            </Fragment>
-                        ))}
-
                         <FormFieldWrapper
                             control={form.control}
                             name="trackInventory"
@@ -384,6 +378,55 @@ function ProductVariantDetailPage() {
                             )}
                         />
                     </DetailFormGrid>
+                    {stockLevels?.map((stockLevel, index) => {
+                        const stockAllocated =
+                            entity?.stockLevels.find(sl => sl.stockLocation.id === stockLevel.stockLocationId)
+                                ?.stockAllocated ?? 0;
+                        const stockLocationName = stockLocationsData?.stockLocations.items?.find(
+                            sl => sl.id === stockLevel.stockLocationId,
+                        )?.name;
+                        const stockLocationNameLabel =
+                            stockLevels.length > 1 ? (
+                                <div className="text-muted-foreground">{stockLocationName}</div>
+                            ) : null;
+                        const stockLabel = (
+                            <>
+                                <Trans>Stock level</Trans>
+                                {stockLocationNameLabel}
+                            </>
+                        );
+                        return (
+                            <DetailFormGrid key={stockLevel.stockLocationId}>
+                                <FormFieldWrapper
+                                    control={form.control}
+                                    name={`stockLevels.${index}.stockOnHand`}
+                                    label={stockLabel}
+                                    render={({ field }) => (
+                                        <Input
+                                            type="number"
+                                            value={field.value}
+                                            onChange={e => {
+                                                field.onChange(e.target.valueAsNumber);
+                                            }}
+                                        />
+                                    )}
+                                />
+                                <div>
+                                    <FormItem>
+                                        <FormLabel>
+                                            <Trans>Allocated</Trans>
+                                        </FormLabel>
+                                        <div className="text-sm pt-1.5">{stockAllocated}</div>
+                                    </FormItem>
+                                </div>
+                            </DetailFormGrid>
+                        );
+                    })}
+                    <AddStockLocationDropdown
+                        availableStockLocations={stockLocationsData?.stockLocations.items ?? []}
+                        usedStockLocationIds={usedStockLocationIds}
+                        onStockLocationSelect={handleAddStockLocation}
+                    />
                 </PageBlock>
 
                 <PageBlock column="side" blockId="facet-values">