187 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}; |