Initial Version

This commit is contained in:
2025-05-23 20:44:23 +02:00
parent d6ce2cbcec
commit 00f18baa2d
27 changed files with 21261 additions and 0 deletions

75
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,75 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import GroceryList from './components/GroceryList';
import ShopList from './components/ShopList';
import ShoppingEventForm from './components/ShoppingEventForm';
import ShoppingEventList from './components/ShoppingEventList';
import Dashboard from './components/Dashboard';
function App() {
return (
<Router>
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
<nav className="bg-white shadow-lg">
<div className="max-w-7xl mx-auto px-4">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<h1 className="text-xl font-bold text-gray-800">
🛒 Grocery Tracker
</h1>
</div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
<Link
to="/"
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium"
>
Dashboard
</Link>
<Link
to="/groceries"
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium"
>
Groceries
</Link>
<Link
to="/shops"
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium"
>
Shops
</Link>
<Link
to="/shopping-events"
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium"
>
Shopping Events
</Link>
<Link
to="/add-purchase"
className="bg-blue-500 hover:bg-blue-700 text-white inline-flex items-center px-3 py-2 text-sm font-medium rounded-md"
>
Add Purchase
</Link>
</div>
</div>
</div>
</div>
</nav>
{/* Main Content */}
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/groceries" element={<GroceryList />} />
<Route path="/shops" element={<ShopList />} />
<Route path="/shopping-events" element={<ShoppingEventList />} />
<Route path="/add-purchase" element={<ShoppingEventForm />} />
</Routes>
</main>
</div>
</Router>
);
}
export default App;

View File

@@ -0,0 +1,135 @@
import React from 'react';
const Dashboard: React.FC = () => {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600">Welcome to your grocery tracker!</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Stats Cards */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-2 bg-blue-100 rounded-md">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Total Shopping Events</p>
<p className="text-2xl font-semibold text-gray-900">-</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-2 bg-green-100 rounded-md">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Total Spent</p>
<p className="text-2xl font-semibold text-gray-900">$-</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-2 bg-yellow-100 rounded-md">
<svg className="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Unique Items</p>
<p className="text-2xl font-semibold text-gray-900">-</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-2 bg-purple-100 rounded-md">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Shops Visited</p>
<p className="text-2xl font-semibold text-gray-900">-</p>
</div>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">Quick Actions</h2>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<button className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<div className="p-2 bg-blue-100 rounded-md mr-3">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
<div>
<p className="font-medium text-gray-900">Add Purchase</p>
<p className="text-sm text-gray-600">Record a new shopping event</p>
</div>
</button>
<button className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<div className="p-2 bg-green-100 rounded-md mr-3">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<div>
<p className="font-medium text-gray-900">Add Grocery</p>
<p className="text-sm text-gray-600">Add a new grocery item</p>
</div>
</button>
<button className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<div className="p-2 bg-purple-100 rounded-md mr-3">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<div>
<p className="font-medium text-gray-900">Add Shop</p>
<p className="text-sm text-gray-600">Register a new shop</p>
</div>
</button>
</div>
</div>
</div>
{/* Recent Activity */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">Recent Shopping Events</h2>
</div>
<div className="p-6">
<div className="text-center py-8">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No shopping events yet</h3>
<p className="mt-1 text-sm text-gray-500">Get started by adding your first purchase!</p>
</div>
</div>
</div>
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,142 @@
import React, { useState, useEffect } from 'react';
import { Grocery } from '../types';
import { groceryApi } from '../services/api';
const GroceryList: React.FC = () => {
const [groceries, setGroceries] = useState<Grocery[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
fetchGroceries();
}, []);
const fetchGroceries = async () => {
try {
setLoading(true);
const response = await groceryApi.getAll();
setGroceries(response.data);
} catch (err) {
setError('Failed to fetch groceries');
console.error('Error fetching groceries:', err);
} finally {
setLoading(false);
}
};
const handleDelete = async (id: number) => {
if (window.confirm('Are you sure you want to delete this grocery item?')) {
try {
await groceryApi.delete(id);
setGroceries(groceries.filter(g => g.id !== id));
} catch (err) {
setError('Failed to delete grocery');
console.error('Error deleting grocery:', err);
}
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Groceries</h1>
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Add New Grocery
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div className="bg-white shadow rounded-lg overflow-hidden">
{groceries.length === 0 ? (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No groceries</h3>
<p className="mt-1 text-sm text-gray-500">Get started by adding your first grocery item.</p>
</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Price
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Category
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Weight
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Organic
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{groceries.map((grocery) => (
<tr key={grocery.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{grocery.name}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">${grocery.price.toFixed(2)}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
{grocery.category}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{grocery.weight ? `${grocery.weight}${grocery.weight_unit}` : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
grocery.organic
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{grocery.organic ? 'Organic' : 'Conventional'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button className="text-indigo-600 hover:text-indigo-900 mr-3">
Edit
</button>
<button
onClick={() => handleDelete(grocery.id)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
};
export default GroceryList;

View File

@@ -0,0 +1,109 @@
import React, { useState, useEffect } from 'react';
import { Shop } from '../types';
import { shopApi } from '../services/api';
const ShopList: React.FC = () => {
const [shops, setShops] = useState<Shop[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
fetchShops();
}, []);
const fetchShops = async () => {
try {
setLoading(true);
const response = await shopApi.getAll();
setShops(response.data);
} catch (err) {
setError('Failed to fetch shops');
console.error('Error fetching shops:', err);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Shops</h1>
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Add New Shop
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div className="bg-white shadow rounded-lg overflow-hidden">
{shops.length === 0 ? (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No shops</h3>
<p className="mt-1 text-sm text-gray-500">Get started by adding your first shop.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-6">
{shops.map((shop) => (
<div key={shop.id} className="bg-white border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">{shop.name}</h3>
<div className="flex space-x-2">
<button className="text-indigo-600 hover:text-indigo-900 text-sm">
Edit
</button>
<button className="text-red-600 hover:text-red-900 text-sm">
Delete
</button>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center text-sm text-gray-600">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{shop.city}
</div>
{shop.address && (
<div className="flex items-start text-sm text-gray-600">
<svg className="w-4 h-4 mr-2 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 7.89a2 2 0 002.83 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{shop.address}
</div>
)}
<div className="flex items-center text-sm text-gray-600">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Added {new Date(shop.created_at).toLocaleDateString()}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default ShopList;

View File

@@ -0,0 +1,253 @@
import React, { useState, useEffect } from 'react';
import { Shop, Grocery, ShoppingEventCreate, GroceryInEvent } from '../types';
import { shopApi, groceryApi, shoppingEventApi } from '../services/api';
const ShoppingEventForm: React.FC = () => {
const [shops, setShops] = useState<Shop[]>([]);
const [groceries, setGroceries] = useState<Grocery[]>([]);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const [formData, setFormData] = useState<ShoppingEventCreate>({
shop_id: 0,
date: new Date().toISOString().split('T')[0],
total_amount: undefined,
notes: '',
groceries: []
});
const [selectedGroceries, setSelectedGroceries] = useState<GroceryInEvent[]>([]);
const [newGroceryItem, setNewGroceryItem] = useState<GroceryInEvent>({
grocery_id: 0,
amount: 1
});
useEffect(() => {
fetchShops();
fetchGroceries();
}, []);
const fetchShops = async () => {
try {
const response = await shopApi.getAll();
setShops(response.data);
} catch (error) {
console.error('Error fetching shops:', error);
}
};
const fetchGroceries = async () => {
try {
const response = await groceryApi.getAll();
setGroceries(response.data);
} catch (error) {
console.error('Error fetching groceries:', error);
}
};
const addGroceryToEvent = () => {
if (newGroceryItem.grocery_id > 0 && newGroceryItem.amount > 0) {
setSelectedGroceries([...selectedGroceries, { ...newGroceryItem }]);
setNewGroceryItem({ grocery_id: 0, amount: 1 });
}
};
const removeGroceryFromEvent = (index: number) => {
setSelectedGroceries(selectedGroceries.filter((_, i) => i !== index));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setMessage('');
try {
const eventData = {
...formData,
groceries: selectedGroceries
};
await shoppingEventApi.create(eventData);
setMessage('Shopping event created successfully!');
// Reset form
setFormData({
shop_id: 0,
date: new Date().toISOString().split('T')[0],
total_amount: undefined,
notes: '',
groceries: []
});
setSelectedGroceries([]);
} catch (error) {
setMessage('Error creating shopping event. Please try again.');
console.error('Error:', error);
} finally {
setLoading(false);
}
};
const getGroceryName = (id: number) => {
const grocery = groceries.find(g => g.id === id);
return grocery ? grocery.name : 'Unknown';
};
return (
<div className="max-w-4xl mx-auto">
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
Add New Purchase
</h3>
{message && (
<div className={`mb-4 p-4 rounded-md ${
message.includes('Error')
? 'bg-red-50 text-red-700'
: 'bg-green-50 text-green-700'
}`}>
{message}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Shop Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Shop
</label>
<select
value={formData.shop_id}
onChange={(e) => setFormData({...formData, shop_id: parseInt(e.target.value)})}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value={0}>Select a shop</option>
{shops.map(shop => (
<option key={shop.id} value={shop.id}>
{shop.name} - {shop.city}
</option>
))}
</select>
</div>
{/* Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Date
</label>
<input
type="date"
value={formData.date}
onChange={(e) => setFormData({...formData, date: e.target.value})}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
{/* Add Groceries Section */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Add Groceries
</label>
<div className="flex space-x-2 mb-4">
<select
value={newGroceryItem.grocery_id}
onChange={(e) => setNewGroceryItem({...newGroceryItem, grocery_id: parseInt(e.target.value)})}
className="flex-1 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value={0}>Select a grocery</option>
{groceries.map(grocery => (
<option key={grocery.id} value={grocery.id}>
{grocery.name} - ${grocery.price} ({grocery.category})
</option>
))}
</select>
<input
type="number"
step="0.1"
min="0.1"
placeholder="Amount"
value={newGroceryItem.amount}
onChange={(e) => setNewGroceryItem({...newGroceryItem, amount: parseFloat(e.target.value)})}
className="w-24 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
onClick={addGroceryToEvent}
className="bg-green-500 hover:bg-green-700 text-white px-4 py-2 rounded-md"
>
Add
</button>
</div>
{/* Selected Groceries List */}
{selectedGroceries.length > 0 && (
<div className="bg-gray-50 rounded-md p-4">
<h4 className="font-medium text-gray-700 mb-2">Selected Items:</h4>
{selectedGroceries.map((item, index) => (
<div key={index} className="flex justify-between items-center py-2 border-b last:border-b-0">
<span>
{getGroceryName(item.grocery_id)} x {item.amount}
</span>
<button
type="button"
onClick={() => removeGroceryFromEvent(index)}
className="text-red-500 hover:text-red-700"
>
Remove
</button>
</div>
))}
</div>
)}
</div>
{/* Total Amount */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Total Amount (optional)
</label>
<input
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={formData.total_amount || ''}
onChange={(e) => setFormData({...formData, total_amount: e.target.value ? parseFloat(e.target.value) : undefined})}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Notes (optional)
</label>
<textarea
rows={3}
value={formData.notes}
onChange={(e) => setFormData({...formData, notes: e.target.value})}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Any additional notes about this purchase..."
/>
</div>
{/* Submit Button */}
<div>
<button
type="submit"
disabled={loading || formData.shop_id === 0 || selectedGroceries.length === 0}
className="w-full bg-blue-500 hover:bg-blue-700 disabled:bg-gray-300 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
>
{loading ? 'Creating...' : 'Create Shopping Event'}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default ShoppingEventForm;

View File

@@ -0,0 +1,123 @@
import React, { useState, useEffect } from 'react';
import { ShoppingEvent } from '../types';
import { shoppingEventApi } from '../services/api';
const ShoppingEventList: React.FC = () => {
const [events, setEvents] = useState<ShoppingEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
fetchEvents();
}, []);
const fetchEvents = async () => {
try {
setLoading(true);
const response = await shoppingEventApi.getAll();
setEvents(response.data);
} catch (err) {
setError('Failed to fetch shopping events');
console.error('Error fetching events:', err);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Shopping Events</h1>
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Add New Event
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div className="bg-white shadow rounded-lg overflow-hidden">
{events.length === 0 ? (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No shopping events</h3>
<p className="mt-1 text-sm text-gray-500">Get started by recording your first purchase.</p>
</div>
) : (
<div className="space-y-4 p-6">
{events.map((event) => (
<div key={event.id} className="border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-lg font-medium text-gray-900">{event.shop.name}</h3>
<p className="text-sm text-gray-600">{event.shop.city}</p>
</div>
<div className="text-right">
<p className="text-sm font-medium text-gray-900">
{new Date(event.date).toLocaleDateString()}
</p>
{event.total_amount && (
<p className="text-lg font-semibold text-green-600">
${event.total_amount.toFixed(2)}
</p>
)}
</div>
</div>
{event.groceries.length > 0 && (
<div className="mb-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Items Purchased:</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{event.groceries.map((grocery) => (
<div key={grocery.id} className="bg-gray-50 rounded px-3 py-2">
<span className="text-sm text-gray-900">{grocery.name}</span>
<span className="text-xs text-gray-600 ml-2">${grocery.price}</span>
</div>
))}
</div>
</div>
)}
{event.notes && (
<div className="mb-4">
<h4 className="text-sm font-medium text-gray-700 mb-1">Notes:</h4>
<p className="text-sm text-gray-600">{event.notes}</p>
</div>
)}
<div className="flex justify-between items-center text-sm">
<span className="text-gray-500">
Event #{event.id} {new Date(event.created_at).toLocaleDateString()}
</span>
<div className="flex space-x-2">
<button className="text-indigo-600 hover:text-indigo-900">
View Details
</button>
<button className="text-red-600 hover:text-red-900">
Delete
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default ShoppingEventList;

12
frontend/src/index.css Normal file
View File

@@ -0,0 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

13
frontend/src/index.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,50 @@
import axios from 'axios';
import { Grocery, GroceryCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate } from '../types';
const BASE_URL = 'http://localhost:8000';
const api = axios.create({
baseURL: BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Grocery API functions
export const groceryApi = {
getAll: () => api.get<Grocery[]>('/groceries/'),
getById: (id: number) => api.get<Grocery>(`/groceries/${id}`),
create: (grocery: GroceryCreate) => api.post<Grocery>('/groceries/', grocery),
update: (id: number, grocery: Partial<GroceryCreate>) =>
api.put<Grocery>(`/groceries/${id}`, grocery),
delete: (id: number) => api.delete(`/groceries/${id}`),
};
// Shop API functions
export const shopApi = {
getAll: () => api.get<Shop[]>('/shops/'),
getById: (id: number) => api.get<Shop>(`/shops/${id}`),
create: (shop: ShopCreate) => api.post<Shop>('/shops/', shop),
update: (id: number, shop: Partial<ShopCreate>) =>
api.put<Shop>(`/shops/${id}`, shop),
delete: (id: number) => api.delete(`/shops/${id}`),
};
// Shopping Event API functions
export const shoppingEventApi = {
getAll: () => api.get<ShoppingEvent[]>('/shopping-events/'),
getById: (id: number) => api.get<ShoppingEvent>(`/shopping-events/${id}`),
create: (event: ShoppingEventCreate) =>
api.post<ShoppingEvent>('/shopping-events/', event),
update: (id: number, event: Partial<ShoppingEventCreate>) =>
api.put<ShoppingEvent>(`/shopping-events/${id}`, event),
delete: (id: number) => api.delete(`/shopping-events/${id}`),
};
// Statistics API functions
export const statsApi = {
getCategories: () => api.get('/stats/categories'),
getShops: () => api.get('/stats/shops'),
};
export default api;

View File

@@ -0,0 +1,72 @@
export interface Grocery {
id: number;
name: string;
price: number;
category: string;
organic: boolean;
weight?: number;
weight_unit: string;
created_at: string;
updated_at?: string;
}
export interface GroceryCreate {
name: string;
price: number;
category: string;
organic: boolean;
weight?: number;
weight_unit: string;
}
export interface Shop {
id: number;
name: string;
city: string;
address?: string;
created_at: string;
}
export interface ShopCreate {
name: string;
city: string;
address?: string;
}
export interface GroceryInEvent {
grocery_id: number;
amount: number;
}
export interface ShoppingEvent {
id: number;
shop_id: number;
date: string;
total_amount?: number;
notes?: string;
created_at: string;
shop: Shop;
groceries: Grocery[];
}
export interface ShoppingEventCreate {
shop_id: number;
date?: string;
total_amount?: number;
notes?: string;
groceries: GroceryInEvent[];
}
export interface CategoryStats {
category: string;
total_spent: number;
item_count: number;
avg_price: number;
}
export interface ShopStats {
shop_name: string;
total_spent: number;
visit_count: number;
avg_per_visit: number;
}