Last Used implementiert
This commit is contained in:
@@ -118,6 +118,29 @@ npm run dev
|
|||||||
### Recipes
|
### Recipes
|
||||||
- `GET /api/recipes` - List recipes (pagination, search, filter)
|
- `GET /api/recipes` - List recipes (pagination, search, filter)
|
||||||
- `GET /api/recipes/:id` - Get single recipe
|
- `GET /api/recipes/:id` - Get single recipe
|
||||||
|
- `lastUsed` wird nur noch gesetzt/aktualisiert wenn ein Rezept aktiv als "zubereitet" markiert wird (siehe Endpoint unten) – reines Ansehen ändert den Wert nicht mehr.
|
||||||
|
|
||||||
|
### Rezept als zubereitet markieren
|
||||||
|
|
||||||
|
POST `/api/recipes/:id/cooked`
|
||||||
|
|
||||||
|
Antwort:
|
||||||
|
```
|
||||||
|
{ "success": true, "message": "Rezept als zubereitet markiert", "data": { "id": 123, "lastUsed": "2025-09-25T18:47:12.123Z" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Verwendung: Button "Zubereitet" auf der Detailseite oder manuell per API. Dadurch kann die Liste nach "Zuletzt zubereitet" sortiert werden (`?sortBy=lastUsed&sortOrder=desc`).
|
||||||
|
|
||||||
|
### Zuletzt zubereitete Rezepte
|
||||||
|
|
||||||
|
`GET /api/recipes/recent?limit=10`
|
||||||
|
|
||||||
|
Liefert die zuletzt markierten Rezepte (nur solche mit gesetztem `lastUsed`) absteigend sortiert. `limit` (1–50) steuert die Anzahl.
|
||||||
|
|
||||||
|
Validierungen / Hinweise:
|
||||||
|
- Zukunftsdaten werden (±1 Minute Toleranz) mit 400 abgelehnt.
|
||||||
|
- Rezepte ohne `lastUsed` können in der Liste mit einem "Nie" Badge erscheinen.
|
||||||
|
- Index auf `last_used` verbessert Sortier-Performance (`CREATE INDEX idx_rezepte_last_used ON Rezepte (last_used);`).
|
||||||
- `POST /api/recipes` - Create recipe
|
- `POST /api/recipes` - Create recipe
|
||||||
- `PUT /api/recipes/:id` - Update recipe
|
- `PUT /api/recipes/:id` - Update recipe
|
||||||
- `DELETE /api/recipes/:id` - Delete recipe
|
- `DELETE /api/recipes/:id` - Delete recipe
|
||||||
|
|||||||
2
backend/dist/routes/recipes.d.ts.map
vendored
2
backend/dist/routes/recipes.d.ts.map
vendored
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"recipes.d.ts","sourceRoot":"","sources":["../../src/routes/recipes.ts"],"names":[],"mappings":"AAIA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAoQxB,eAAe,MAAM,CAAC"}
|
{"version":3,"file":"recipes.d.ts","sourceRoot":"","sources":["../../src/routes/recipes.ts"],"names":[],"mappings":"AAIA,QAAA,MAAM,MAAM,4CAAW,CAAC;AA6QxB,eAAe,MAAM,CAAC"}
|
||||||
7
backend/dist/routes/recipes.js
vendored
7
backend/dist/routes/recipes.js
vendored
@@ -109,6 +109,13 @@ router.get('/:id', async (req, res, next) => {
|
|||||||
console.log(`Could not load separate ingredients for recipe ${recipe.recipeNumber}:`, ingredientError);
|
console.log(`Could not load separate ingredients for recipe ${recipe.recipeNumber}:`, ingredientError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const now = new Date();
|
||||||
|
prisma.recipe.update({
|
||||||
|
where: { id: recipeId },
|
||||||
|
data: { lastUsed: now },
|
||||||
|
select: { id: true }
|
||||||
|
}).catch(err => console.warn('lastUsed update failed for recipe', recipeId, err.message));
|
||||||
|
recipe.lastUsed = now;
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: recipe,
|
data: recipe,
|
||||||
|
|||||||
2
backend/dist/routes/recipes.js.map
vendored
2
backend/dist/routes/recipes.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -22,12 +22,14 @@ model Recipe {
|
|||||||
ingredients String? @map("Zutaten") @db.Text
|
ingredients String? @map("Zutaten") @db.Text
|
||||||
instructions String? @map("Zubereitung") @db.Text
|
instructions String? @map("Zubereitung") @db.Text
|
||||||
comment String? @map("Kommentar") @db.Text
|
comment String? @map("Kommentar") @db.Text
|
||||||
|
lastUsed DateTime? @map("last_used") @db.DateTime(0)
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
images RecipeImage[]
|
images RecipeImage[]
|
||||||
ingredientsList Ingredient[]
|
ingredientsList Ingredient[]
|
||||||
|
|
||||||
@@map("Rezepte")
|
@@map("Rezepte")
|
||||||
|
@@index([lastUsed])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Ingredient {
|
model Ingredient {
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
|||||||
where.category = { contains: category as string };
|
where.category = { contains: category as string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whitelist allowed sort fields to prevent SQL injection vectors via Prisma
|
||||||
|
const allowedSortFields = new Set(['title','category','servings','recipeNumber','lastUsed','id']);
|
||||||
|
const chosenSortField = allowedSortFields.has(String(sortBy)) ? String(sortBy) : 'title';
|
||||||
|
|
||||||
const [recipes, total] = await Promise.all([
|
const [recipes, total] = await Promise.all([
|
||||||
prisma.recipe.findMany({
|
prisma.recipe.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -57,7 +61,8 @@ router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
|||||||
images: true,
|
images: true,
|
||||||
ingredientsList: true,
|
ingredientsList: true,
|
||||||
},
|
},
|
||||||
orderBy: { [sortBy as string]: sortOrder as 'asc' | 'desc' },
|
// @ts-ignore lastUsed exists in DB schema; Prisma typing cache issue workaround
|
||||||
|
orderBy: { [chosenSortField]: sortOrder === 'desc' ? 'desc' : 'asc' },
|
||||||
skip,
|
skip,
|
||||||
take: limitNum,
|
take: limitNum,
|
||||||
}),
|
}),
|
||||||
@@ -79,8 +84,24 @@ router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Neueste zubereitete Rezepte (nach lastUsed sortiert) – vor :id Route platzieren
|
||||||
|
router.get('/recent', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { limit = '10' } = req.query;
|
||||||
|
const take = Math.min(Math.max(parseInt(limit as string) || 10, 1), 50);
|
||||||
|
const recipes = await prisma.recipe.findMany({
|
||||||
|
// @ts-ignore lastUsed exists
|
||||||
|
where: { lastUsed: { not: null } },
|
||||||
|
// @ts-ignore lastUsed exists
|
||||||
|
orderBy: { lastUsed: 'desc' },
|
||||||
|
take,
|
||||||
|
include: { images: true }
|
||||||
|
});
|
||||||
|
return res.json({ success: true, data: recipes });
|
||||||
|
} catch (error) { next(error); }
|
||||||
|
});
|
||||||
|
|
||||||
// Get single recipe by ID
|
// Get single recipe by ID
|
||||||
// Get recipe by ID
|
|
||||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
@@ -135,6 +156,8 @@ router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lastUsed wird nicht mehr beim Ansehen aktualisiert, sondern explizit über einen "cooked" Endpoint
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: recipe,
|
data: recipe,
|
||||||
@@ -262,4 +285,45 @@ router.delete('/:id', async (req: Request, res: Response, next: NextFunction) =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Endpoint um ein Rezept als "zubereitet" zu markieren -> aktualisiert lastUsed
|
||||||
|
router.post('/:id/cooked', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
if (!id) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Recipe ID is required' });
|
||||||
|
}
|
||||||
|
const recipeId = parseInt(id, 10);
|
||||||
|
if (isNaN(recipeId)) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Invalid recipe ID format' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let customDate: Date | null = null;
|
||||||
|
if (req.body && req.body.lastUsed) {
|
||||||
|
const parsed = new Date(req.body.lastUsed);
|
||||||
|
if (isNaN(parsed.getTime())) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Ungültiges Datum (lastUsed)' });
|
||||||
|
}
|
||||||
|
customDate = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDate = customDate || new Date();
|
||||||
|
if (targetDate.getTime() > Date.now() + 60_000) { // 1 Minute Puffer
|
||||||
|
return res.status(400).json({ success: false, message: 'Datum liegt in der Zukunft' });
|
||||||
|
}
|
||||||
|
const updated = await prisma.recipe.update({
|
||||||
|
where: { id: recipeId },
|
||||||
|
// @ts-ignore lastUsed exists
|
||||||
|
data: { lastUsed: targetDate }
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-ignore lastUsed field is present in database
|
||||||
|
return res.json({ success: true, message: 'Rezept als zubereitet markiert', data: { id: updated.id, lastUsed: (updated as any).lastUsed } });
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as any).code === 'P2025') {
|
||||||
|
return res.status(404).json({ success: false, message: 'Recipe not found' });
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -633,6 +633,42 @@
|
|||||||
padding: 40px;
|
padding: 40px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* --- Cook Date Modal (appended) --- */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog {
|
||||||
|
background: #fff;
|
||||||
|
padding: 24px 28px;
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
box-shadow: 0 10px 30px -5px rgba(0,0,0,0.35);
|
||||||
|
animation: fadeIn 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-dialog h3 { margin: 0 0 12px; font-size: 1.25rem; }
|
||||||
|
.modal-field-group { margin-bottom: 16px; }
|
||||||
|
.modal-field-group label { display:block; font-size: 0.75rem; letter-spacing: .5px; font-weight:600; margin-bottom:4px; color:#555; text-transform: uppercase; }
|
||||||
|
.modal-field-group input { width: 100%; padding: 8px 10px; border: 1px solid #cbd5e0; border-radius: 6px; font-size: 0.95rem; box-sizing: border-box; }
|
||||||
|
.modal-field-inline { display: flex; gap: 10px; }
|
||||||
|
.modal-field-inline .modal-field-group { flex: 1; margin-bottom: 0; }
|
||||||
|
|
||||||
|
.modal-actions { display:flex; justify-content:flex-end; gap:10px; margin-top: 18px; }
|
||||||
|
.modal-actions button { padding:8px 14px; border:none; border-radius:6px; cursor:pointer; font-weight:500; font-size: 0.9rem; }
|
||||||
|
.modal-actions .primary { background:#2f855a; color:#fff; }
|
||||||
|
.modal-actions .primary:hover { background:#276749; }
|
||||||
|
.modal-actions .secondary { background:#e2e8f0; }
|
||||||
|
.modal-actions .secondary:hover { background:#cbd5e0; }
|
||||||
|
.modal-actions .danger { background:#dc3545; color:#fff; }
|
||||||
|
.modal-actions .danger:hover { background:#c82333; }
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.recipe-detail {
|
.recipe-detail {
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ const RecipeDetail: React.FC = () => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [editingImages, setEditingImages] = useState(false);
|
const [editingImages, setEditingImages] = useState(false);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
const [markingCooked, setMarkingCooked] = useState(false);
|
||||||
|
const [showCookModal, setShowCookModal] = useState(false);
|
||||||
|
const [cookDate, setCookDate] = useState<string>(''); // YYYY-MM-DD
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadRecipe = async () => {
|
const loadRecipe = async () => {
|
||||||
@@ -124,6 +127,43 @@ const RecipeDetail: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openCookModal = () => {
|
||||||
|
if (!recipe) return;
|
||||||
|
const now = new Date();
|
||||||
|
const isoDate = now.toISOString().slice(0,10); // YYYY-MM-DD
|
||||||
|
setCookDate(isoDate);
|
||||||
|
setShowCookModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitCooked = async () => {
|
||||||
|
if (!recipe) return;
|
||||||
|
if (cookDate) {
|
||||||
|
const d = new Date(`${cookDate}T00:00:00`);
|
||||||
|
if (d.getTime() > Date.now() + 60_000) {
|
||||||
|
setError('Datum liegt in der Zukunft');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setMarkingCooked(true);
|
||||||
|
let iso: string | undefined = undefined;
|
||||||
|
if (cookDate) {
|
||||||
|
const combined = new Date(`${cookDate}T00:00:00`);
|
||||||
|
if (!isNaN(combined.getTime())) iso = combined.toISOString();
|
||||||
|
}
|
||||||
|
const resp = await recipeApi.markRecipeCooked(recipe.id, iso);
|
||||||
|
if (resp.success) {
|
||||||
|
setRecipe({ ...recipe, lastUsed: resp.data.lastUsed || undefined });
|
||||||
|
setShowCookModal(false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Mark cooked failed', e);
|
||||||
|
setError('Fehler beim Speichern des Datums');
|
||||||
|
} finally {
|
||||||
|
setMarkingCooked(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="recipe-detail">
|
<div className="recipe-detail">
|
||||||
@@ -192,6 +232,14 @@ const RecipeDetail: React.FC = () => {
|
|||||||
>
|
>
|
||||||
📸 {editingImages ? 'Fertig' : 'Bilder verwalten'}
|
📸 {editingImages ? 'Fertig' : 'Bilder verwalten'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={openCookModal}
|
||||||
|
className="edit-button"
|
||||||
|
title="Datum der letzten Zubereitung setzen"
|
||||||
|
style={{ marginRight: '8px', background: '#2f855a' }}
|
||||||
|
>
|
||||||
|
🍳 {recipe.lastUsed ? 'Zubereitet ändern' : 'Zubereitet'}
|
||||||
|
</button>
|
||||||
<Link to={`/recipes/${recipe.id}/edit`} className="edit-button">
|
<Link to={`/recipes/${recipe.id}/edit`} className="edit-button">
|
||||||
✏️ Bearbeiten
|
✏️ Bearbeiten
|
||||||
</Link>
|
</Link>
|
||||||
@@ -200,6 +248,24 @@ const RecipeDetail: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{showCookModal && (
|
||||||
|
<div className="modal-overlay" role="dialog" aria-modal="true">
|
||||||
|
<div className="modal-dialog">
|
||||||
|
<h3>Datum der Zubereitung</h3>
|
||||||
|
<div className="modal-field-group">
|
||||||
|
<label htmlFor="cook-date">Datum</label>
|
||||||
|
<input id="cook-date" type="date" value={cookDate} onChange={e => setCookDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
{recipe.lastUsed && (
|
||||||
|
<button className="danger" disabled={markingCooked} onClick={async () => { try { setMarkingCooked(true); const resp = await recipeApi.markRecipeCooked(recipe.id, undefined); if (resp.success) { setRecipe({ ...recipe, lastUsed: resp.data.lastUsed || undefined }); } } finally { setMarkingCooked(false); setShowCookModal(false);} }}>Jetzt setzen</button>
|
||||||
|
)}
|
||||||
|
<button className="secondary" onClick={() => setShowCookModal(false)} disabled={markingCooked}>Abbrechen</button>
|
||||||
|
<button className="primary" onClick={submitCooked} disabled={markingCooked}>{markingCooked ? 'Speichere...' : 'Speichern'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="recipe-content">
|
<div className="recipe-content">
|
||||||
@@ -249,6 +315,14 @@ const RecipeDetail: React.FC = () => {
|
|||||||
<span className="meta-label">Portionen:</span>
|
<span className="meta-label">Portionen:</span>
|
||||||
<span className="meta-value">👥 {recipe.servings}</span>
|
<span className="meta-value">👥 {recipe.servings}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{recipe.lastUsed && (
|
||||||
|
<div className="meta-item">
|
||||||
|
<span className="meta-label">Zuletzt zubereitet:</span>
|
||||||
|
<span className="meta-value">
|
||||||
|
{new Date(recipe.lastUsed).toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -198,6 +198,29 @@
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recipe-lastused {
|
||||||
|
background: #2f855a;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-never {
|
||||||
|
background: #e53e3e;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.recipe-actions {
|
.recipe-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ const RecipeList: React.FC = () => {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [category, setCategory] = useState('');
|
const [category, setCategory] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [sortBy, setSortBy] = useState<'title' | 'lastUsed'>('title');
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||||
const [pagination, setPagination] = useState({
|
const [pagination, setPagination] = useState({
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
@@ -50,8 +52,8 @@ const RecipeList: React.FC = () => {
|
|||||||
category: category || undefined,
|
category: category || undefined,
|
||||||
page,
|
page,
|
||||||
limit: 12,
|
limit: 12,
|
||||||
sortBy: 'title',
|
sortBy,
|
||||||
sortOrder: 'asc',
|
sortOrder,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
@@ -71,7 +73,7 @@ const RecipeList: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadRecipes();
|
loadRecipes();
|
||||||
}, [debouncedSearch, category, page]);
|
}, [debouncedSearch, category, page, sortBy, sortOrder]);
|
||||||
|
|
||||||
const handleSearchSubmit = (e: React.FormEvent) => {
|
const handleSearchSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -140,6 +142,24 @@ const RecipeList: React.FC = () => {
|
|||||||
<option value="Fisch">Fisch</option>
|
<option value="Fisch">Fisch</option>
|
||||||
<option value="Dessert">Dessert</option>
|
<option value="Dessert">Dessert</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={e => { setSortBy(e.target.value as any); setPage(1); }}
|
||||||
|
className="category-select"
|
||||||
|
style={{ marginLeft: '8px' }}
|
||||||
|
>
|
||||||
|
<option value="title">Titel</option>
|
||||||
|
<option value="lastUsed">Zuletzt zubereitet</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => { setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); setPage(1); }}
|
||||||
|
className="search-button"
|
||||||
|
style={{ marginLeft: '4px' }}
|
||||||
|
title="Sortierreihenfolge umschalten"
|
||||||
|
>
|
||||||
|
{sortOrder === 'asc' ? '⬆️' : '⬇️'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -188,6 +208,13 @@ const RecipeList: React.FC = () => {
|
|||||||
<span className="recipe-category">{recipe.category}</span>
|
<span className="recipe-category">{recipe.category}</span>
|
||||||
)}
|
)}
|
||||||
<span className="recipe-servings">👥 {recipe.servings} Portionen</span>
|
<span className="recipe-servings">👥 {recipe.servings} Portionen</span>
|
||||||
|
{recipe.lastUsed ? (
|
||||||
|
<span className="recipe-lastused" title={new Date(recipe.lastUsed).toLocaleString('de-DE')}>
|
||||||
|
🍳 {new Date(recipe.lastUsed).toLocaleDateString('de-DE')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="recipe-never" title="Noch nie zubereitet">🆕 Nie</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="recipe-actions">
|
<div className="recipe-actions">
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface Recipe {
|
|||||||
ingredients?: string;
|
ingredients?: string;
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
lastUsed?: string; // ISO Datum vom Backend
|
||||||
images?: RecipeImage[];
|
images?: RecipeImage[];
|
||||||
ingredientsList?: Ingredient[];
|
ingredientsList?: Ingredient[];
|
||||||
}
|
}
|
||||||
@@ -106,6 +107,12 @@ export const recipeApi = {
|
|||||||
const response = await api.delete(`/recipes/${id}`);
|
const response = await api.delete(`/recipes/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Mark recipe as cooked (updates lastUsed)
|
||||||
|
markRecipeCooked: async (id: number, lastUsed?: string): Promise<ApiResponse<{ id: number; lastUsed: string | null }>> => {
|
||||||
|
const response = await api.post(`/recipes/${id}/cooked`, lastUsed ? { lastUsed } : {});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ingredient API methods
|
// Ingredient API methods
|
||||||
|
|||||||
Reference in New Issue
Block a user