Files
flyer-crawler.projectium.com/components/ExtractedDataTable.tsx

187 lines
10 KiB
TypeScript

import React, { useMemo, useState } from 'react';
import type { FlyerItem, MasterGroceryItem, ShoppingList } from '../types';
import { formatUnitPrice } from '../utils/unitConverter';
import { Session } from '@supabase/supabase-js';
import { PlusCircleIcon } from './icons/PlusCircleIcon';
interface ExtractedDataTableProps {
items: FlyerItem[];
totalActiveItems?: number;
watchedItems?: MasterGroceryItem[];
masterItems: MasterGroceryItem[];
unitSystem: 'metric' | 'imperial';
session: Session | null;
onAddItem: (itemName: string, category: string) => Promise<void>;
shoppingLists: ShoppingList[];
activeListId: number | null;
onAddItemToList: (masterItemId: number) => void;
}
export const ExtractedDataTable: React.FC<ExtractedDataTableProps> = ({ items, totalActiveItems, watchedItems = [], masterItems, unitSystem, session, onAddItem, shoppingLists, activeListId, onAddItemToList }) => {
const [categoryFilter, setCategoryFilter] = useState('all');
const watchedItemIds = useMemo(() => new Set(watchedItems.map(item => item.id)), [watchedItems]);
const masterItemsMap = useMemo(() => new Map(masterItems.map(item => [item.id, item.name])), [masterItems]);
const activeShoppingListItems = useMemo(() => {
if (!activeListId) return new Set();
const activeList = shoppingLists.find(list => list.id === activeListId);
return new Set(activeList?.items.map(item => item.master_item_id));
}, [shoppingLists, activeListId]);
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,
}));
}, [items, masterItemsMap]);
const sortedItems = useMemo(() => {
const filtered = categoryFilter === 'all'
? itemsWithCanonicalNames
: itemsWithCanonicalNames.filter(item => item.category_name === categoryFilter);
if (watchedItemIds.size === 0) {
return filtered;
}
const watched = [];
const others = [];
for (const item of filtered) {
const isWatched = item.master_item_id && watchedItemIds.has(item.master_item_id);
if (isWatched) {
watched.push(item);
} else {
others.push(item);
}
}
return [...watched, ...others];
}, [itemsWithCanonicalNames, watchedItemIds, categoryFilter]);
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 = (totalActiveItems && totalActiveItems > 0)
? `Item List (${items.length} in flyer / ${totalActiveItems} total active deals)`
: `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, index) => {
const canonicalName = item.resolved_canonical_name;
const isWatched = item.master_item_id && watchedItemIds.has(item.master_item_id);
const isInList = !!(item.master_item_id && activeShoppingListItems.has(item.master_item_id));
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 key={item.id || `${item.item}-${index}`} 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 flex-shrink-0 ml-4">
{session && 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>
)}
{session && !isWatched && canonicalName && (
<button
onClick={() => onAddItem(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>
);
})}
</tbody>
</table>
)}
</div>
</div>
);
};