Files
flyer-crawler.projectium.com/src/features/flyer/ExtractedDataTable.tsx

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>
);
};