Files
stockfill/src/screens/ActivePickListScreen.tsx
T

562 lines
20 KiB
TypeScript

// src/screens/ActivePickListScreen.tsx
import {
Button,
Checkbox,
Container,
FormControlLabel,
IconButton,
InputAdornment,
Stack,
TextField,
Tooltip,
Typography,
FormControl,
FormLabel,
RadioGroup,
Radio,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import { Link as RouterLink, useParams, useNavigate } from 'react-router-dom';
import { useEffect, useMemo, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import {
useAreas,
useCategories,
usePickItems,
usePickList,
useProducts,
} from '../hooks/dataHooks';
import { useDatabase } from '../context/DBProvider';
import { PickItemRow } from '../components/PickItemRow';
import { PickItem } from '../models/PickItem';
import { Product } from '../models/Product';
import { ProductAutocomplete } from '../components/ProductAutocomplete';
import { normalizeName } from '../utils/stringUtils';
const ActivePickListScreen = () => {
const { id } = useParams();
const pickList = usePickList(id);
const items = usePickItems(id);
const products = useProducts();
const areas = useAreas();
const categoriesList = useCategories();
const db = useDatabase();
const navigate = useNavigate();
const [query, setQuery] = useState('');
const [showPicked, setShowPicked] = useState(true);
const [itemState, setItemState] = useState<PickItem[]>(items ?? []);
const [isBatchUpdating, setIsBatchUpdating] = useState(false);
const [packagingFilter, setPackagingFilter] = useState<'all' | 'units' | 'cartons'>('all');
// NEW: search within the list and category filter for the visible list
const [listSearch, setListSearch] = useState('');
const [categoryFilter, setCategoryFilter] = useState<'all' | string>('all');
// Keep local item state in sync with DB-driven `items`
useEffect(() => {
setItemState((current: PickItem[]) => {
const currentById = new Map<string, PickItem>(current.map((item: PickItem) => [item.id, item]));
return (items ?? []).map((incoming: PickItem) => {
const local = currentById.get(incoming.id);
if (!local) return incoming;
const localUpdatedAt = local.updated_at ?? 0;
const incomingUpdatedAt = incoming.updated_at ?? 0;
if (localUpdatedAt > incomingUpdatedAt) {
return local;
}
return incoming;
});
});
}, [items]);
// Reworked visible logic:
const itemsAfterShowPicked = useMemo(
() => (showPicked ? itemState : itemState.filter((item) => item.status !== 'picked')),
[itemState, showPicked],
);
const packagingInfo = useMemo(() => {
const visible = itemsAfterShowPicked ?? [];
const uniqueValues = new Set(visible.map((it) => !!it.is_carton));
return {
visibleCount: visible.length,
uniquePackagingCount: uniqueValues.size,
};
}, [itemsAfterShowPicked]);
useEffect(() => {
if (packagingInfo.visibleCount === 0 || packagingInfo.uniquePackagingCount === 1) {
setPackagingFilter('all');
}
}, [packagingInfo.visibleCount, packagingInfo.uniquePackagingCount]);
const allItemsPicked = useMemo(
() => itemState.length > 0 && itemState.every((item) => item.status === 'picked'),
[itemState],
);
const productMap = useMemo(() => {
const map = new Map<string, Product>();
products.forEach((product: Product) => {
map.set(product.id, product);
});
return map;
}, [products]);
const areaName = useMemo(
() => areas.find((area) => area.id === pickList?.area_id)?.name ?? 'Area',
[areas, pickList?.area_id],
);
const sortedProducts = useMemo(() => {
const dedupedById = new Map<string, Product>();
products.forEach((product: Product) => {
const existing = dedupedById.get(product.id);
if (!existing || product.updated_at > existing.updated_at) {
dedupedById.set(product.id, product);
}
});
const dedupedByName = new Map<string, Product>();
dedupedById.forEach((product: Product) => {
const normalizedName = product.name.trim().toLowerCase();
const existing = dedupedByName.get(normalizedName);
if (!existing || product.updated_at > existing.updated_at) {
dedupedByName.set(normalizedName, product);
}
});
return Array.from(dedupedByName.values()).sort((a, b) => {
const normalizedNameA = normalizeName(a.name);
const normalizedNameB = normalizeName(b.name);
const nameComparison = normalizedNameA.localeCompare(normalizedNameB, undefined, {
sensitivity: 'base',
});
if (nameComparison !== 0) {
return nameComparison;
}
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
});
}, [products]);
// Build a category id -> name map for display and name -> id map for resolution
const categoriesById = useMemo(() => new Map(categoriesList.map((c) => [c.id, c.name])), [categoriesList]);
const categoryNameToId = useMemo(() => new Map(categoriesList.map((c) => [c.name.trim().toLowerCase(), c.id])), [categoriesList]);
// Filter products by pickList.categories (now stored as ids). Fallback: resolve name -> id
const categoryFilteredProducts = useMemo(() => {
if (!pickList?.categories || pickList.categories.length === 0) {
return sortedProducts;
}
const categoryIdsKnown = new Set(categoriesList.map((c) => c.id));
const allowedCategoryIds = new Set<string>();
pickList.categories.forEach((entry: string) => {
if (!entry) return;
if (categoryIdsKnown.has(entry)) {
// entry already an id
allowedCategoryIds.add(entry);
} else {
// maybe it's a name (legacy) — resolve
const resolved = categoryNameToId.get(entry.trim().toLowerCase());
if (resolved) allowedCategoryIds.add(resolved);
}
});
if (allowedCategoryIds.size === 0) return sortedProducts;
return sortedProducts.filter((product: Product) => allowedCategoryIds.has(product.category));
}, [pickList?.categories, sortedProducts, categoriesList, categoryNameToId]);
const productIdsInList = useMemo(() => new Set(itemState.map((item) => item.product_id)), [itemState]);
const filteredProducts = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
const availableProducts = categoryFilteredProducts.filter((product: Product) => !productIdsInList.has(product.id));
if (!normalizedQuery) return availableProducts;
return availableProducts.filter((product: Product) => {
const catName = categoriesById.get(product.category) ?? product.category ?? '';
const searchableText = `${product.name} ${catName} ${product.barcode ?? ''}`.toLowerCase();
return searchableText.includes(normalizedQuery);
});
}, [categoryFilteredProducts, productIdsInList, query, categoriesById]);
// If a filtered list leaves a previously-selected product missing, callers (ProductAutocomplete) will handle clear.
// --- Handlers (restored) ---
const handleIncrementQuantity = async (itemId: string) => {
const existing = await db.pickItems.get(itemId);
if (!existing) return;
const nextQuantity = existing.quantity + 1;
setItemState((current) => current.map((item) => (item.id === itemId ? { ...item, quantity: nextQuantity, updated_at: Date.now() } : item)));
await db.pickItems.update(itemId, {
quantity: nextQuantity,
updated_at: Date.now(),
});
};
const handleDecrementQuantity = async (itemId: string) => {
const existing = await db.pickItems.get(itemId);
if (!existing) return;
const nextQuantity = Math.max(1, (existing.quantity || 1) - 1);
setItemState((current) => current.map((item) => (item.id === itemId ? { ...item, quantity: nextQuantity, updated_at: Date.now() } : item)));
await db.pickItems.update(itemId, {
quantity: nextQuantity,
updated_at: Date.now(),
});
};
const handleToggleCarton = async (itemId: string) => {
const existing = await db.pickItems.get(itemId);
if (!existing) return;
const nextCartonFlag = !existing.is_carton;
const nextQuantity = existing.quantity || 1;
setItemState((current) => current.map((item) => (item.id === itemId ? { ...item, is_carton: nextCartonFlag, quantity: nextQuantity, updated_at: Date.now() } : item)));
await db.pickItems.update(itemId, {
is_carton: nextCartonFlag,
quantity: nextQuantity,
updated_at: Date.now(),
});
};
const handleStatusChange = async (itemId: string, status: PickItem['status']) => {
const nextStatus = status === 'picked' ? 'picked' : 'pending';
setItemState((current) => current.map((item) => (item.id === itemId ? { ...item, status: nextStatus, updated_at: Date.now() } : item)));
await db.pickItems.update(itemId, { status: nextStatus, updated_at: Date.now() });
};
const handleDeleteItem = async (itemId: string) => {
setItemState((current) => current.filter((item) => item.id !== itemId));
await db.pickItems.delete(itemId);
};
const handleMarkAllPicked = async () => {
if (!id) return;
setShowPicked(true);
const timestamp = Date.now();
setItemState((current) => current.map((item) => ({ ...item, status: 'picked', updated_at: timestamp })));
setIsBatchUpdating(true);
// if itemState is empty at invocation, fall back to DB items
const itemsToUpdate = itemState.length > 0 ? itemState : (items ?? []);
try {
await Promise.all(itemsToUpdate.map((item: PickItem) => db.pickItems.update(item.id, { status: 'picked', updated_at: timestamp })));
const refreshedItems = await db.pickItems.where('pick_list_id').equals(id as string).toArray();
setItemState(refreshedItems);
} finally {
setIsBatchUpdating(false);
}
};
const addOrUpdateItem = async (product: Product) => {
if (!id) return;
const timestamp = Date.now();
const existing = itemState.find((item) => item.product_id === product.id && item.is_carton === false);
if (existing) {
setItemState((current) =>
current.map((item) =>
item.id === existing.id
? { ...item, quantity: item.quantity + 1, updated_at: timestamp }
: item,
),
);
await db.pickItems.update(existing.id, {
quantity: existing.quantity + 1,
updated_at: timestamp,
});
} else {
const newItem: PickItem = {
id: uuidv4(),
pick_list_id: id as string,
product_id: product.id,
quantity: 1,
is_carton: false,
status: 'pending',
created_at: timestamp,
updated_at: timestamp,
};
setItemState((current) => [...current, newItem]);
await db.pickItems.add({
...newItem,
});
}
setQuery('');
};
const returnToLists = () => {
navigate('/pick-lists');
};
// Sort the items that are actually visible (after showPicked and packaging filter)
const visibleItems = useMemo(() => {
let arr = [...itemsAfterShowPicked];
if (packagingFilter === 'units') {
arr = arr.filter((item) => !item.is_carton);
} else if (packagingFilter === 'cartons') {
arr = arr.filter((item) => item.is_carton);
}
arr.sort((a, b) => {
const nameA = normalizeName(productMap.get(a.product_id)?.name ?? '');
const nameB = normalizeName(productMap.get(b.product_id)?.name ?? '');
const nameComparison = nameA.localeCompare(nameB, undefined, { sensitivity: 'base' });
if (nameComparison !== 0) return nameComparison;
const timeA = a.created_at ?? a.updated_at ?? 0;
const timeB = b.created_at ?? b.updated_at ?? 0;
return timeA - timeB;
});
return arr;
}, [itemsAfterShowPicked, productMap, packagingFilter]);
// --- NEW: build category options from pickList.categories (fallback to all categories) ---
const categoryOptions = useMemo(() => {
// helper maps
const idToName = new Map(categoriesList.map((c) => [c.id, c.name]));
const nameToId = new Map(categoriesList.map((c) => [c.name.trim().toLowerCase(), c.id]));
if (!pickList?.categories || pickList.categories.length === 0) {
// fallback: show all categories
return categoriesList
.map((c) => ({ id: c.id, name: c.name }))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
}
// Build from pickList.categories while resolving legacy names -> ids
const seen = new Map<string, string>();
pickList.categories.forEach((entry: string) => {
if (!entry) return;
if (idToName.has(entry)) {
seen.set(entry, idToName.get(entry)!);
} else {
const resolved = nameToId.get(entry.trim().toLowerCase());
if (resolved) {
seen.set(resolved, idToName.get(resolved)!);
}
}
});
// Convert to array and sort by name
return Array.from(seen.entries())
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
}, [pickList?.categories, categoriesList]);
// --- NEW: apply categoryFilter and listSearch to visibleItems ---
const visibleItemsFiltered = useMemo(() => {
const q = listSearch.trim().toLowerCase();
let arr = visibleItems;
if (categoryFilter !== 'all') {
arr = arr.filter((item) => {
const product = productMap.get(item.product_id);
return (product?.category ?? '') === categoryFilter;
});
}
if (!q) return arr;
return arr.filter((item) => {
const product = productMap.get(item.product_id);
const catName = categoriesById.get(product?.category ?? '') ?? product?.category ?? '';
const searchableText = `${product?.name ?? ''} ${catName} ${product?.barcode ?? ''}`.toLowerCase();
return searchableText.includes(q);
});
}, [visibleItems, productMap, listSearch, categoryFilter, categoriesById]);
return (
<Container sx={{ py: 4 }}>
<Stack spacing={2} mb={2}>
<Typography variant="h5">{areaName} List</Typography>
<Stack spacing={1.5} sx={{ p: 2, borderRadius: 1, bgcolor: 'grey.50' }}>
<Typography variant="subtitle2" color="text.secondary">
Add products to this list
</Typography>
<ProductAutocomplete
availableProducts={filteredProducts}
onSelect={(product: Product) => {
void addOrUpdateItem(product);
}}
onQueryChange={(q: string) => setQuery(q)}
/>
{filteredProducts.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No available products
</Typography>
) : null}
<Typography variant="caption" color="text.secondary">
Selecting a product immediately adds it to the pick list.
</Typography>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
flexWrap="wrap"
rowGap={1}
>
<FormControl component="fieldset" data-testid="packaging-filter-group" sx={{ ml: { xs: 0, sm: 2 } }}>
<FormLabel component="legend" sx={{ fontSize: '0.875rem' }}>
Packaging
</FormLabel>
<RadioGroup
row
aria-label="packaging-filter"
name="packaging-filter"
value={packagingFilter}
onChange={(_, value) => setPackagingFilter(value as 'all' | 'units' | 'cartons')}
>
<FormControlLabel
value="all"
control={<Radio size="small" />}
label="All"
data-testid="packaging-filter-all"
/>
<FormControlLabel
value="units"
control={<Radio size="small" />}
label="Units"
disabled={packagingInfo.visibleCount === 0 || packagingInfo.uniquePackagingCount === 1}
data-testid="packaging-filter-units"
/>
<FormControlLabel
value="cartons"
control={<Radio size="small" />}
label="Cartons"
disabled={packagingInfo.visibleCount === 0 || packagingInfo.uniquePackagingCount === 1}
data-testid="packaging-filter-cartons"
/>
</RadioGroup>
</FormControl>
<Stack direction="row" spacing={1} alignItems="center" sx={{ ml: { xs: 0, sm: 2 } }}>
<FormControlLabel
control={
<Checkbox
checked={showPicked}
onChange={(event) => setShowPicked(event.target.checked)}
disabled={allItemsPicked}
/>
}
label="Show picked"
/>
<Button
variant="contained"
size="small"
onClick={handleMarkAllPicked}
disabled={isBatchUpdating}
>
{isBatchUpdating ? 'Picking...' : 'Pick Complete'}
</Button>
</Stack>
</Stack>
{/* NEW: Search List (left) and Category dropdown (right) on the same row */}
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
flexWrap="wrap"
rowGap={1}
sx={{ mt: 1 }}
>
<TextField
size="small"
placeholder="Search list"
variant="outlined"
value={listSearch}
onChange={(e) => setListSearch(e.target.value)}
sx={{ flexGrow: 1, minWidth: 160 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
<TextField
select
size="small"
SelectProps={{ native: true }}
label="Filter by category"
value={categoryFilter}
onChange={(e) => setCategoryFilter((e.target.value as any) ?? 'all')}
sx={{ width: { xs: '100%', sm: 240 }, ml: { xs: 0, sm: 2 }, mt: { xs: 1, sm: 0 } }}
>
<option value="all">All categories</option>
{categoryOptions.map((opt) => (
<option key={opt.id} value={opt.id}>
{opt.name}
</option>
))}
</TextField>
</Stack>
</Stack>
</Stack>
{pickList?.notes ? (
<Typography variant="body2" color="text.secondary" mb={2}>
{pickList.notes}
</Typography>
) : null}
<Stack spacing={1}>
{/* NEW: empty-filter message */}
{visibleItemsFiltered.length === 0 ? (
visibleItems.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No items in this pick list
</Typography>
) : (
<Typography variant="body2" color="text.secondary">
No items match the filter
</Typography>
)
) : (
visibleItemsFiltered.map((item) => (
<PickItemRow
key={item.id}
item={item}
product={productMap.get(item.product_id)}
onIncrementQuantity={() => handleIncrementQuantity(item.id)}
onDecrementQuantity={() => handleDecrementQuantity(item.id)}
onToggleCarton={() => handleToggleCarton(item.id)}
onStatusChange={(status) => handleStatusChange(item.id, status)}
onDelete={() => handleDeleteItem(item.id)}
/>
))
)}
</Stack>
<Button fullWidth sx={{ mt: 3 }} variant="outlined" onClick={returnToLists}>
Save and Return
</Button>
</Container>
);
};
export default ActivePickListScreen