Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 | /** * @file PriceTrendCard.tsx * @module pages/analytics/blocks/PriceTrendCard * * @summary * Supplier-aware type-ahead + price trend chart (A3). * * Enterprise rules * - Never let cross-supplier selections through when a supplier is chosen. * - Debounced queries; the chart fetches only after an explicit selection. * - If supplier-scoped search is unsupported server-side, keep options empty * (no silent global downgrade) so the UI can message clearly. */ import * as React from 'react'; import { Card, CardContent, Typography, Skeleton, Box, TextField, Stack } from '@mui/material'; import Autocomplete from '@mui/material/Autocomplete'; import type { AutocompleteRenderInputParams } from '@mui/material/Autocomplete'; import { useTheme as useMuiTheme } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; import { useQuery, keepPreviousData } from '@tanstack/react-query'; import { getPriceTrend, searchItemsForSupplier, searchItemsGlobal, type ItemRef, type PricePoint, } from '../../../api/analytics'; import { useDebounced } from '../../../hooks/useDebounced'; import { ResponsiveContainer, LineChart, CartesianGrid, XAxis, YAxis, Tooltip, Line } from 'recharts'; import { useSettings } from '../../../hooks/useSettings'; import { formatDate, formatNumber } from '../../../utils/formatters'; export type PriceTrendCardProps = { from?: string; to?: string; supplierId?: string | null }; /** * Local narrow type (ItemRef already includes optional supplierId). * Kept to document intent: UI enforces supplier scoping even if BE ignores it. */ type ItemWithSupplier = ItemRef & { supplierId?: string | null }; export default function PriceTrendCard({ from, to, supplierId }: PriceTrendCardProps) { const { t } = useTranslation(['analytics']); const muiTheme = useMuiTheme(); const { userPreferences } = useSettings(); const formatDateLabel = React.useCallback( (value: string | number) => { const str = String(value); const formatted = formatDate(str, userPreferences.dateFormat); return formatted || str; }, [userPreferences.dateFormat] ); // --------------------------------------------------------------------------- // Type-ahead state (stable selection + debounced query) // --------------------------------------------------------------------------- /** Controlled input text inside the Autocomplete. */ const [itemQuery, setItemQuery] = React.useState(''); /** Debounce to avoid flooding the server while typing. */ const debouncedQuery = useDebounced(itemQuery, 250); /** Keep the selected item as an object — DO NOT derive from options. */ const [selectedItem, setSelectedItem] = React.useState<ItemRef | null>(null); const selectedItemId = selectedItem?.id ?? ''; /** Reset text + selection when supplier changes (prevents cross-supplier leaks). */ React.useEffect(() => { setItemQuery(''); setSelectedItem(null); }, [supplierId]); // --------------------------------------------------------------------------- // Search (global vs supplier-scoped) — keep previous options during refetch // --------------------------------------------------------------------------- const itemSearchQ = useQuery<ItemRef[]>({ queryKey: ['analytics', 'itemSearch', supplierId ?? null, debouncedQuery], queryFn: async () => { if (!debouncedQuery) return []; if (supplierId) return searchItemsForSupplier(supplierId, debouncedQuery, 50); return searchItemsGlobal(debouncedQuery, 50); }, enabled: debouncedQuery.length >= 1, staleTime: 30_000, // v5 replacement for keepPreviousData: true placeholderData: keepPreviousData, refetchOnWindowFocus: false, }); /** * Final options: * - Start from async results. * - If supplierId is set → filter by supplier on the client as well. * - Apply text narrowing too (belt-and-suspenders). */ const baseOptions: ItemWithSupplier[] = React.useMemo(() => { const base = (itemSearchQ.data ?? []) as ItemWithSupplier[]; const sid = supplierId ?? ''; const q = debouncedQuery.trim().toLowerCase(); const bySupplier = sid ? base.filter((it) => (it.supplierId ?? '') === sid) : base; const byQuery = q ? bySupplier.filter((it) => it.name.toLowerCase().includes(q)) : bySupplier; return byQuery.slice(0, 50); }, [itemSearchQ.data, supplierId, debouncedQuery]); /** * Keep the selected item visible even while refetching: * if the current options don't include it (transiently), union it back. */ const options: ItemWithSupplier[] = React.useMemo(() => { if (selectedItem && !baseOptions.some((o) => o.id === selectedItem.id)) { return [selectedItem as ItemWithSupplier, ...baseOptions]; } return baseOptions; }, [baseOptions, selectedItem]); // --------------------------------------------------------------------------- // Price trend series — fetched only after explicit selection // --------------------------------------------------------------------------- const priceQ = useQuery<PricePoint[]>({ queryKey: ['analytics', 'priceTrend', selectedItemId, from, to], queryFn: () => getPriceTrend(selectedItemId, { from, to, supplierId: supplierId ?? undefined }), enabled: !!selectedItemId, }); const priceData = React.useMemo( () => [...(priceQ.data ?? [])].sort((a, b) => (a?.date ?? '').localeCompare(b?.date ?? '')), [priceQ.data] ); // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- return ( <Card> <CardContent> <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems="center" justifyContent="space-between" sx={{ mb: 1 }} > <Typography variant="subtitle1">{t('analytics:cards.priceTrend')}</Typography> <Autocomplete<ItemRef, false, false, false> sx={{ minWidth: 320 }} options={options} getOptionLabel={(o) => o.name} loading={itemSearchQ.isLoading} /** Keep selection stable; don't derive it from options */ value={selectedItem} onChange={(_e, val) => { setSelectedItem(val); // Optional UX: mirror selection text so search stays in sync if (val) setItemQuery(val.name); }} /** Controlled input text (what the user types) */ inputValue={itemQuery} onInputChange={(_e, val) => setItemQuery(val)} /** Search UX */ forcePopupIcon={false} clearOnBlur={false} selectOnFocus handleHomeEndKeys /** We already filtered; don't let MUI filter again */ filterOptions={(x) => x} isOptionEqualToValue={(o, v) => o.id === v.id} renderInput={(params: AutocompleteRenderInputParams) => { const typed = debouncedQuery.trim().length > 0; const showNoMatches = !!supplierId && typed && options.length === 0; const showTypeHint = !!supplierId && !typed; return ( <TextField {...params} size="small" label={t('analytics:item')} placeholder={t('analytics:priceTrend.selectSupplierShort')} helperText={ showNoMatches ? t('analytics:priceTrend.noItemsForSupplier') : showTypeHint ? t('analytics:priceTrend.typeToSearch', 'Start typing to search…') : ' ' } FormHelperTextProps={{ sx: { minHeight: 20, mt: 0.5 } }} /> ); }} noOptionsText={ debouncedQuery ? t('analytics:priceTrend.noItemsForSupplier') : t('analytics:priceTrend.selectSupplierShort') } /> </Stack> {!selectedItemId || priceQ.isLoading ? ( <Skeleton variant="rounded" height={220} /> ) : priceData.length === 0 ? ( <Box sx={{ height: 220, display: 'grid', placeItems: 'center', color: 'text.secondary' }}> {t('analytics:cards.noData')} </Box> ) : ( <Box sx={{ height: 260 }}> <ResponsiveContainer width="100%" height="100%"> <LineChart data={priceData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}> <CartesianGrid strokeDasharray="3 3" /> <XAxis dataKey="date" tickFormatter={formatDateLabel} /> <YAxis domain={['auto', 'auto']} tickFormatter={(value) => formatNumber(Number(value), userPreferences.numberFormat, 2)} /> <Tooltip labelFormatter={(value) => formatDateLabel(value as string)} formatter={(value: number | string) => typeof value === 'number' ? formatNumber(value, userPreferences.numberFormat, 2) : value } /> <Line type="monotone" dataKey="price" stroke={muiTheme.palette.primary.main} strokeWidth={2} dot={{ r: 2 }} activeDot={{ r: 4 }} connectNulls isAnimationActive={false} /> </LineChart> </ResponsiveContainer> </Box> )} </CardContent> </Card> ); } |