270 lines
11 KiB
TypeScript
270 lines
11 KiB
TypeScript
// src/features/flyer/ExtractedDataTable.tsx
|
|
import React, { useMemo, useState, useCallback, memo } from 'react';
|
|
import type { FlyerItem, ShoppingListItem } from '../../types'; // Import ShoppingListItem
|
|
import { formatUnitPrice } from '../../utils/unitConverter';
|
|
import { PlusCircleIcon } from '../../components/icons/PlusCircleIcon';
|
|
import { useAuth } from '../../hooks/useAuth';
|
|
import { useUserData } from '../../hooks/useUserData';
|
|
import { useMasterItems } from '../../hooks/useMasterItems';
|
|
import { useWatchedItems } from '../../hooks/useWatchedItems';
|
|
import { useShoppingLists } from '../../hooks/useShoppingLists';
|
|
|
|
export interface ExtractedDataTableProps {
|
|
items: FlyerItem[];
|
|
unitSystem: 'metric' | 'imperial';
|
|
}
|
|
|
|
/**
|
|
* Props for the ExtractedDataTableRow component.
|
|
*/
|
|
interface ExtractedDataTableRowProps {
|
|
item: FlyerItem & { resolved_canonical_name: string | null };
|
|
isWatched: boolean;
|
|
isInList: boolean;
|
|
unitSystem: 'metric' | 'imperial';
|
|
isAuthenticated: boolean;
|
|
activeListId: number | null;
|
|
onAddItemToList: (masterItemId: number) => void;
|
|
onAddWatchedItem: (itemName: string, category: string) => void;
|
|
}
|
|
|
|
/**
|
|
* A memoized component that renders a single row in the extracted data table.
|
|
* Using React.memo prevents this component from re-rendering if its props have not changed.
|
|
*/
|
|
const ExtractedDataTableRow: React.FC<ExtractedDataTableRowProps> = memo(
|
|
({
|
|
item,
|
|
isWatched,
|
|
isInList,
|
|
unitSystem,
|
|
isAuthenticated,
|
|
activeListId,
|
|
onAddItemToList,
|
|
onAddWatchedItem,
|
|
}) => {
|
|
const canonicalName = item.resolved_canonical_name;
|
|
const itemNameClass = isWatched
|
|
? 'text-sm font-bold text-green-600 dark:text-green-400'
|
|
: 'text-sm font-semibold text-gray-900 dark:text-white';
|
|
|
|
const shouldShowCanonical =
|
|
canonicalName && canonicalName.toLowerCase() !== item.item.toLowerCase();
|
|
const formattedUnitPrice = formatUnitPrice(item.unit_price, unitSystem);
|
|
|
|
return (
|
|
<tr className="group hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
|
<td className="px-6 py-4 whitespace-normal">
|
|
<div className="flex justify-between items-center mb-2">
|
|
<div className={itemNameClass}>{item.item}</div>
|
|
<div className="flex items-center space-x-2 shrink-0 ml-4">
|
|
{isAuthenticated && canonicalName && !isInList && (
|
|
<button
|
|
onClick={() => onAddItemToList(item.master_item_id!)}
|
|
disabled={!activeListId}
|
|
className="text-gray-400 hover:text-brand-primary disabled:text-gray-300 disabled:cursor-not-allowed dark:text-gray-500 dark:hover:text-brand-light transition-colors"
|
|
title={
|
|
activeListId ? `Add ${canonicalName} to list` : 'Select a shopping list first'
|
|
}
|
|
>
|
|
<PlusCircleIcon className="w-5 h-5" />
|
|
</button>
|
|
)}
|
|
{isAuthenticated && !isWatched && canonicalName && (
|
|
<button
|
|
onClick={() =>
|
|
onAddWatchedItem(canonicalName, item.category_name || 'Other/Miscellaneous')
|
|
}
|
|
className="text-xs bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-brand-primary dark:text-brand-light font-semibold py-1 px-2.5 rounded-md transition-colors duration-200"
|
|
title={`Add '${canonicalName}' to your watchlist`}
|
|
>
|
|
+ Watch
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-x-6 gap-y-1 text-xs text-gray-600 dark:text-gray-400">
|
|
<div className="flex items-baseline space-x-2">
|
|
<span className="font-medium text-gray-500 w-16 shrink-0">Price:</span>
|
|
<span>{item.price_display}</span>
|
|
</div>
|
|
<div className="flex items-baseline space-x-2">
|
|
<span className="font-medium text-gray-500 w-16 shrink-0">Deal:</span>
|
|
<div className="flex items-baseline">
|
|
<span>{item.quantity}</span>
|
|
{item.quantity_num && (
|
|
<span className="ml-1.5 text-gray-400 dark:text-gray-500">
|
|
({item.quantity_num})
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-baseline space-x-2">
|
|
<span className="font-medium text-gray-500 w-16 shrink-0">Unit Price:</span>
|
|
<div className="flex items-baseline">
|
|
<span className="font-semibold text-gray-700 dark:text-gray-300">
|
|
{formattedUnitPrice.price}
|
|
</span>
|
|
{formattedUnitPrice.unit && (
|
|
<span className="ml-1 text-xs text-gray-500 dark:text-gray-400">
|
|
{formattedUnitPrice.unit}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-baseline space-x-2">
|
|
<span className="font-medium text-gray-500 w-16 shrink-0">Category:</span>
|
|
<span className="italic">{item.category_name}</span>
|
|
{shouldShowCanonical && (
|
|
<span className="ml-4 italic text-gray-400">(Canonical: {canonicalName})</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
},
|
|
);
|
|
|
|
export const ExtractedDataTable: React.FC<ExtractedDataTableProps> = ({ items, unitSystem }) => {
|
|
const { userProfile } = useAuth();
|
|
const { watchedItems } = useUserData();
|
|
const { masterItems } = useMasterItems();
|
|
const { addWatchedItem } = useWatchedItems();
|
|
const { activeListId, addItemToList, shoppingLists } = useShoppingLists(); // Get shoppingLists
|
|
const [categoryFilter, setCategoryFilter] = useState('all');
|
|
|
|
const watchedItemIds = useMemo(
|
|
() => new Set(watchedItems.map((item) => item.master_grocery_item_id)),
|
|
[watchedItems],
|
|
);
|
|
const masterItemsMap = useMemo(
|
|
() => new Map(masterItems.map((item) => [item.master_grocery_item_id, item.name])),
|
|
[masterItems],
|
|
);
|
|
|
|
const activeShoppingListItems = useMemo(() => {
|
|
if (!activeListId) return new Set();
|
|
const activeList = shoppingLists.find((list) => list.shopping_list_id === activeListId);
|
|
if (!activeList) return new Set();
|
|
return new Set(activeList.items.map((item: ShoppingListItem) => item.master_item_id));
|
|
}, [shoppingLists, activeListId]);
|
|
|
|
const handleAddItemToList = useCallback(
|
|
(masterItemId: number) => {
|
|
if (!activeListId) return;
|
|
addItemToList(activeListId, { masterItemId });
|
|
},
|
|
[activeListId, addItemToList],
|
|
);
|
|
|
|
const handleAddWatchedItem = useCallback(
|
|
(itemName: string, category: string) => {
|
|
addWatchedItem(itemName, category);
|
|
},
|
|
[addWatchedItem],
|
|
);
|
|
|
|
const availableCategories = useMemo(() => {
|
|
const cats = new Set(items.map((i) => i.category_name).filter((c): c is string => !!c));
|
|
return Array.from(cats).sort();
|
|
}, [items]);
|
|
|
|
const itemsWithCanonicalNames = useMemo(() => {
|
|
return items.map((item) => ({
|
|
...item,
|
|
resolved_canonical_name: item.master_item_id
|
|
? (masterItemsMap.get(item.master_item_id) ?? null)
|
|
: null,
|
|
}));
|
|
}, [items, masterItemsMap]);
|
|
|
|
const sortedItems = useMemo(() => {
|
|
const filtered =
|
|
categoryFilter === 'all'
|
|
? itemsWithCanonicalNames
|
|
: itemsWithCanonicalNames.filter((item) => item.category_name === categoryFilter);
|
|
|
|
// Sort the array: watched items first, then alphabetically by item name.
|
|
// This is more efficient than creating two separate arrays and merging them.
|
|
return [...filtered].sort((a, b) => {
|
|
const aIsWatched = a.master_item_id ? watchedItemIds.has(a.master_item_id) : false;
|
|
const bIsWatched = b.master_item_id ? watchedItemIds.has(b.master_item_id) : false;
|
|
|
|
if (aIsWatched && !bIsWatched) return -1; // a comes first
|
|
if (!aIsWatched && bIsWatched) return 1; // b comes first
|
|
|
|
// If both are watched or both are not, sort alphabetically by item name.
|
|
return a.item.localeCompare(b.item);
|
|
});
|
|
}, [itemsWithCanonicalNames, categoryFilter, watchedItemIds]);
|
|
|
|
if (items.length === 0) {
|
|
return (
|
|
<div className="text-center p-8 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
|
<p className="text-gray-500">No items extracted yet.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const title = `Item List (${items.length})`;
|
|
|
|
return (
|
|
<div className="overflow-hidden bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm">
|
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">{title}</h3>
|
|
{availableCategories.length > 1 && (
|
|
<select
|
|
value={categoryFilter}
|
|
onChange={(e) => setCategoryFilter(e.target.value)}
|
|
className="block pl-3 pr-8 py-1.5 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary"
|
|
aria-label="Filter by category"
|
|
>
|
|
<option value="all">All Categories</option>
|
|
{availableCategories.map((cat) => (
|
|
<option key={cat} value={cat}>
|
|
{cat}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
{sortedItems.length === 0 ? (
|
|
<div className="text-center p-8 text-gray-500 dark:text-gray-400">
|
|
No items found for the selected category.
|
|
</div>
|
|
) : (
|
|
<table className="min-w-full">
|
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
{sortedItems.map((item) => {
|
|
const isWatched = !!(
|
|
item.master_item_id && watchedItemIds.has(item.master_item_id)
|
|
);
|
|
const isInList = !!(
|
|
item.master_item_id && activeShoppingListItems.has(item.master_item_id)
|
|
);
|
|
|
|
return (
|
|
<ExtractedDataTableRow
|
|
key={item.flyer_item_id}
|
|
item={item}
|
|
isWatched={isWatched}
|
|
isInList={isInList}
|
|
unitSystem={unitSystem}
|
|
isAuthenticated={!!userProfile}
|
|
activeListId={activeListId}
|
|
onAddItemToList={handleAddItemToList}
|
|
onAddWatchedItem={handleAddWatchedItem}
|
|
/>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|