Compare commits
10 Commits
93e8bf9e41
...
741141168b
| Author | SHA1 | Date | |
|---|---|---|---|
| 741141168b | |||
| 6cc79836d5 | |||
| 5ac8f1768f | |||
| 7bdadbe962 | |||
| 2151dff4db | |||
| e831480860 | |||
| 92922533ac | |||
| 16b70bd818 | |||
| 5325f4827e | |||
| c26c2e1973 |
@@ -1,11 +1,14 @@
|
||||
package com.recipeapp.shopping;
|
||||
|
||||
import com.recipeapp.common.RequiresHouseholdRole;
|
||||
import com.recipeapp.common.ResourceNotFoundException;
|
||||
import com.recipeapp.recipe.HouseholdResolver;
|
||||
import com.recipeapp.shopping.dto.*;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@@ -19,8 +22,21 @@ public class ShoppingListController {
|
||||
this.householdResolver = householdResolver;
|
||||
}
|
||||
|
||||
@GetMapping("/v1/shopping-list")
|
||||
public ShoppingListResponse getByWeekStart(
|
||||
@RequestParam(required = false) LocalDate weekStart,
|
||||
Principal principal) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
ShoppingListResponse response = shoppingService.getByWeekStart(householdId, weekStart);
|
||||
if (response == null) {
|
||||
throw new ResourceNotFoundException("No shopping list for this week");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@PostMapping("/v1/week-plans/{id}/shopping-list")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@RequiresHouseholdRole("planner")
|
||||
public ShoppingListResponse generateFromPlan(@PathVariable UUID id, Principal principal) {
|
||||
UUID householdId = householdResolver.resolve(principal.getName());
|
||||
return shoppingService.generateFromPlan(householdId, id);
|
||||
|
||||
@@ -18,10 +18,11 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.TemporalAdjusters;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.Set;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
@@ -52,6 +53,18 @@ public class ShoppingService {
|
||||
}
|
||||
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ShoppingListResponse getByWeekStart(UUID householdId, LocalDate weekStart) {
|
||||
if (weekStart == null) {
|
||||
weekStart = LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
|
||||
}
|
||||
|
||||
return shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(householdId, weekStart)
|
||||
.map(this::toResponse)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
|
||||
public ShoppingListResponse generateFromPlan(UUID householdId, UUID weekPlanId) {
|
||||
WeekPlan weekPlan = weekPlanRepository.findById(weekPlanId)
|
||||
.orElseThrow(() -> new ResourceNotFoundException("Week plan not found"));
|
||||
@@ -60,25 +73,23 @@ public class ShoppingService {
|
||||
throw new ResourceNotFoundException("Week plan not found");
|
||||
}
|
||||
|
||||
var household = weekPlan.getHousehold();
|
||||
|
||||
ShoppingList shoppingList = new ShoppingList(household, weekPlan);
|
||||
shoppingList = shoppingListRepository.save(shoppingList);
|
||||
// Find or create the shopping list
|
||||
ShoppingList shoppingList = shoppingListRepository
|
||||
.findByHouseholdIdAndWeekPlanWeekStart(householdId, weekPlan.getWeekStart())
|
||||
.orElseGet(() -> {
|
||||
var newList = new ShoppingList(weekPlan.getHousehold(), weekPlan);
|
||||
return shoppingListRepository.save(newList);
|
||||
});
|
||||
|
||||
// Aggregate ingredients across all slots/recipes
|
||||
// Key: ingredientId + unit -> merged data
|
||||
Map<String, MergedIngredient> merged = new LinkedHashMap<>();
|
||||
|
||||
for (var slot : weekPlan.getSlots()) {
|
||||
var recipe = slot.getRecipe();
|
||||
for (RecipeIngredient ri : recipe.getIngredients()) {
|
||||
Ingredient ingredient = ri.getIngredient();
|
||||
|
||||
// Filter out staples
|
||||
if (ingredient.isStaple()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String key = ingredient.getId().toString() + "|" + ri.getUnit();
|
||||
merged.computeIfAbsent(key, k -> new MergedIngredient(ingredient, ri.getUnit()))
|
||||
.addQuantity(ri.getQuantity())
|
||||
@@ -86,19 +97,46 @@ public class ShoppingService {
|
||||
}
|
||||
}
|
||||
|
||||
// Create shopping list items
|
||||
for (MergedIngredient mi : merged.values()) {
|
||||
ShoppingListItem item = new ShoppingListItem(
|
||||
shoppingList,
|
||||
mi.ingredient,
|
||||
null,
|
||||
mi.totalQuantity,
|
||||
mi.unit,
|
||||
mi.recipeIds.stream().distinct().toArray(UUID[]::new)
|
||||
);
|
||||
shoppingList.getItems().add(item);
|
||||
// Build index of existing generated items by merge key
|
||||
Map<String, ShoppingListItem> existingByKey = new HashMap<>();
|
||||
List<ShoppingListItem> customItems = new ArrayList<>();
|
||||
for (ShoppingListItem item : shoppingList.getItems()) {
|
||||
if (item.getSourceRecipes() != null && item.getSourceRecipes().length > 0) {
|
||||
// Generated item
|
||||
String key = (item.getIngredient() != null ? item.getIngredient().getId().toString() : "") + "|" + item.getUnit();
|
||||
existingByKey.put(key, item);
|
||||
} else {
|
||||
customItems.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge: update existing, add new, collect keys to keep
|
||||
Set<String> mergedKeys = new HashSet<>();
|
||||
for (MergedIngredient mi : merged.values()) {
|
||||
String key = mi.ingredient.getId().toString() + "|" + mi.unit;
|
||||
mergedKeys.add(key);
|
||||
|
||||
ShoppingListItem existing = existingByKey.get(key);
|
||||
if (existing != null) {
|
||||
// Update quantity and sources, preserve check state
|
||||
existing.setQuantity(mi.totalQuantity);
|
||||
existing.setSourceRecipes(mi.recipeIds.stream().distinct().toArray(UUID[]::new));
|
||||
} else {
|
||||
// New item
|
||||
ShoppingListItem item = new ShoppingListItem(
|
||||
shoppingList, mi.ingredient, null, mi.totalQuantity, mi.unit,
|
||||
mi.recipeIds.stream().distinct().toArray(UUID[]::new));
|
||||
shoppingList.getItems().add(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove generated items no longer in the plan
|
||||
shoppingList.getItems().removeIf(item ->
|
||||
item.getSourceRecipes() != null && item.getSourceRecipes().length > 0
|
||||
&& !mergedKeys.contains(
|
||||
(item.getIngredient() != null ? item.getIngredient().getId().toString() : "") + "|" + item.getUnit()));
|
||||
|
||||
shoppingList.setGeneratedAt(java.time.Instant.now());
|
||||
shoppingListRepository.save(shoppingList);
|
||||
|
||||
return toResponse(shoppingList);
|
||||
|
||||
4
backend/src/main/resources/application-docker.yml
Normal file
4
backend/src/main/resources/application-docker.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
spring:
|
||||
flyway:
|
||||
locations: classpath:db/migration,classpath:db/seed
|
||||
out-of-order: true
|
||||
@@ -1,2 +1,2 @@
|
||||
ALTER TABLE shopping_list
|
||||
ADD COLUMN generated_at timestamptz NOT NULL DEFAULT now();
|
||||
ADD COLUMN IF NOT EXISTS generated_at timestamptz NOT NULL DEFAULT now();
|
||||
|
||||
@@ -49,6 +49,32 @@ class ShoppingListControllerTest {
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getByWeekStartShouldReturn200() throws Exception {
|
||||
var response = new ShoppingListResponse(LIST_ID, PLAN_ID, Instant.now(), 3, List.of());
|
||||
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(shoppingService.getByWeekStart(eq(HOUSEHOLD_ID), any())).thenReturn(response);
|
||||
|
||||
mockMvc.perform(get("/v1/shopping-list")
|
||||
.param("weekStart", "2026-04-06")
|
||||
.principal(() -> "sarah@example.com"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.id").value(LIST_ID.toString()))
|
||||
.andExpect(jsonPath("$.filteredStaplesCount").value(3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getByWeekStartShouldReturn404WhenNoListExists() throws Exception {
|
||||
when(householdResolver.resolve("sarah@example.com")).thenReturn(HOUSEHOLD_ID);
|
||||
when(shoppingService.getByWeekStart(eq(HOUSEHOLD_ID), any())).thenReturn(null);
|
||||
|
||||
mockMvc.perform(get("/v1/shopping-list")
|
||||
.param("weekStart", "2026-04-06")
|
||||
.principal(() -> "sarah@example.com"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateFromPlanShouldReturn201() throws Exception {
|
||||
var recipeId = UUID.randomUUID();
|
||||
|
||||
@@ -92,6 +92,46 @@ class ShoppingServiceTest {
|
||||
} catch (Exception e) { throw new RuntimeException(e); }
|
||||
}
|
||||
|
||||
// ── Get by week start ──
|
||||
|
||||
@Test
|
||||
void getByWeekStartShouldReturnListForGivenWeek() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var list = testShoppingList(household, plan);
|
||||
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
|
||||
.thenReturn(Optional.of(list));
|
||||
|
||||
ShoppingListResponse result = shoppingService.getByWeekStart(HOUSEHOLD_ID, WEEK_START);
|
||||
|
||||
assertThat(result.id()).isEqualTo(list.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getByWeekStartShouldDefaultToCurrentWeekWhenNull() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var list = testShoppingList(household, plan);
|
||||
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(eq(HOUSEHOLD_ID), any(LocalDate.class)))
|
||||
.thenReturn(Optional.of(list));
|
||||
|
||||
ShoppingListResponse result = shoppingService.getByWeekStart(HOUSEHOLD_ID, null);
|
||||
|
||||
assertThat(result).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getByWeekStartShouldReturnNullWhenNoListExists() {
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
ShoppingListResponse result = shoppingService.getByWeekStart(HOUSEHOLD_ID, WEEK_START);
|
||||
|
||||
assertThat(result).isNull();
|
||||
}
|
||||
|
||||
// ── Generate ──
|
||||
|
||||
@Test
|
||||
@@ -121,9 +161,11 @@ class ShoppingServiceTest {
|
||||
plan.getSlots().add(slot2);
|
||||
|
||||
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
|
||||
.thenReturn(Optional.empty());
|
||||
when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> {
|
||||
ShoppingList sl = i.getArgument(0);
|
||||
setId(sl, ShoppingList.class, UUID.randomUUID());
|
||||
if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID());
|
||||
return sl;
|
||||
});
|
||||
when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe1, recipe2));
|
||||
@@ -144,6 +186,59 @@ class ShoppingServiceTest {
|
||||
assertThat(cheeseItem.quantity()).isEqualByComparingTo(new BigDecimal("200.00"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateFromPlanShouldMergeWhenListAlreadyExists() {
|
||||
var household = testHousehold();
|
||||
var plan = testWeekPlan(household);
|
||||
var existingList = testShoppingList(household, plan);
|
||||
|
||||
// Existing generated item: 2 tomatoes
|
||||
var tomato = testIngredient(household, "Tomatoes", false);
|
||||
var existingItem = testItem(existingList, tomato, new BigDecimal("2.00"), "pcs");
|
||||
existingItem.setSourceRecipes(new UUID[]{UUID.randomUUID()});
|
||||
existingList.getItems().add(existingItem);
|
||||
|
||||
// Existing custom item (should be preserved)
|
||||
var customItem = new ShoppingListItem(existingList, null, "Paper towels",
|
||||
new BigDecimal("1"), "", new UUID[0]);
|
||||
setId(customItem, ShoppingListItem.class, UUID.randomUUID());
|
||||
customItem.setChecked(true);
|
||||
existingList.getItems().add(customItem);
|
||||
|
||||
// New plan: 5 tomatoes + cheese (tomato quantity updated, cheese added)
|
||||
var recipe = testRecipe(household, "Pasta");
|
||||
var cheese = testIngredient(household, "Cheese", false);
|
||||
recipe.getIngredients().add(new RecipeIngredient(recipe, tomato, new BigDecimal("5.00"), "pcs", (short) 1));
|
||||
recipe.getIngredients().add(new RecipeIngredient(recipe, cheese, new BigDecimal("200.00"), "g", (short) 2));
|
||||
var slot = new WeekPlanSlot(plan, recipe, WEEK_START);
|
||||
setId(slot, WeekPlanSlot.class, UUID.randomUUID());
|
||||
plan.getSlots().add(slot);
|
||||
|
||||
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, plan.getWeekStart()))
|
||||
.thenReturn(Optional.of(existingList));
|
||||
when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> i.getArgument(0));
|
||||
when(recipeRepository.findAllById(any())).thenReturn(List.of(recipe));
|
||||
|
||||
ShoppingListResponse result = shoppingService.generateFromPlan(HOUSEHOLD_ID, plan.getId());
|
||||
|
||||
// Should have 3 items: tomato (updated), cheese (new), paper towels (preserved custom)
|
||||
assertThat(result.items()).hasSize(3);
|
||||
|
||||
var tomatoResult = result.items().stream()
|
||||
.filter(i -> "Tomatoes".equals(i.name())).findFirst().orElseThrow();
|
||||
assertThat(tomatoResult.quantity()).isEqualByComparingTo(new BigDecimal("5.00"));
|
||||
|
||||
var cheeseResult = result.items().stream()
|
||||
.filter(i -> "Cheese".equals(i.name())).findFirst().orElseThrow();
|
||||
assertThat(cheeseResult.quantity()).isEqualByComparingTo(new BigDecimal("200.00"));
|
||||
|
||||
// Custom item preserved with check state
|
||||
var customResult = result.items().stream()
|
||||
.filter(i -> "Paper towels".equals(i.name())).findFirst().orElseThrow();
|
||||
assertThat(customResult.isChecked()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateFromPlanShouldThrowWhenPlanNotFound() {
|
||||
var planId = UUID.randomUUID();
|
||||
@@ -382,9 +477,11 @@ class ShoppingServiceTest {
|
||||
// no slots added
|
||||
|
||||
when(weekPlanRepository.findById(plan.getId())).thenReturn(Optional.of(plan));
|
||||
when(shoppingListRepository.findByHouseholdIdAndWeekPlanWeekStart(HOUSEHOLD_ID, WEEK_START))
|
||||
.thenReturn(Optional.empty());
|
||||
when(shoppingListRepository.save(any(ShoppingList.class))).thenAnswer(i -> {
|
||||
ShoppingList sl = i.getArgument(0);
|
||||
setId(sl, ShoppingList.class, UUID.randomUUID());
|
||||
if (sl.getId() == null) setId(sl, ShoppingList.class, UUID.randomUUID());
|
||||
return sl;
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
49
frontend/src/lib/api/schema.d.ts
vendored
49
frontend/src/lib/api/schema.d.ts
vendored
@@ -452,6 +452,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/shopping-list": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getByWeekStart"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/ingredients": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -624,6 +640,11 @@ export interface components {
|
||||
/** Format: uuid */
|
||||
recipeId: string;
|
||||
};
|
||||
RecipeRef: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
ShoppingListItemResponse: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
@@ -636,13 +657,17 @@ export interface components {
|
||||
isChecked?: boolean;
|
||||
/** Format: uuid */
|
||||
checkedBy?: string;
|
||||
sourceRecipes?: string[];
|
||||
sourceRecipes?: components["schemas"]["RecipeRef"][];
|
||||
};
|
||||
ShoppingListResponse: {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
/** Format: uuid */
|
||||
weekPlanId?: string;
|
||||
/** Format: date-time */
|
||||
generatedAt?: string;
|
||||
/** Format: int32 */
|
||||
filteredStaplesCount?: number;
|
||||
items?: components["schemas"]["ShoppingListItemResponse"][];
|
||||
};
|
||||
TagCreateRequest: {
|
||||
@@ -1902,6 +1927,28 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getByWeekStart: {
|
||||
parameters: {
|
||||
query?: {
|
||||
weekStart?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ShoppingListResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
searchIngredients: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
86
frontend/src/lib/shopping/AddCustomItem.svelte
Normal file
86
frontend/src/lib/shopping/AddCustomItem.svelte
Normal file
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
interface Props {
|
||||
listId: string;
|
||||
}
|
||||
|
||||
let { listId }: Props = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
let customName = $state('');
|
||||
let quantity = $state('1');
|
||||
let unit = $state('');
|
||||
</script>
|
||||
|
||||
{#if !expanded}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (expanded = true)}
|
||||
class="flex w-full items-center gap-2 rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:border-[var(--green-light)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
<span class="text-[16px]">+</span>
|
||||
Artikel hinzufügen
|
||||
</button>
|
||||
{:else}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addItem"
|
||||
use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
customName = '';
|
||||
quantity = '1';
|
||||
unit = '';
|
||||
expanded = false;
|
||||
};
|
||||
}}
|
||||
class="flex flex-col gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-3"
|
||||
>
|
||||
<input type="hidden" name="listId" value={listId} />
|
||||
|
||||
<input
|
||||
type="text"
|
||||
name="customName"
|
||||
bind:value={customName}
|
||||
placeholder="Artikelname"
|
||||
required
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-3 py-1.5 font-[var(--font-sans)] text-[14px] text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--green)] focus:outline-none"
|
||||
/>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
name="quantity"
|
||||
bind:value={quantity}
|
||||
min="0"
|
||||
step="any"
|
||||
class="w-20 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-3 py-1.5 font-[var(--font-sans)] text-[14px] text-[var(--color-text)] focus:border-[var(--green)] focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="unit"
|
||||
bind:value={unit}
|
||||
placeholder="Einheit"
|
||||
class="flex-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-3 py-1.5 font-[var(--font-sans)] text-[14px] text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--green)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (expanded = false)}
|
||||
class="rounded-[var(--radius-md)] px-3 py-1.5 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!customName.trim()}
|
||||
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-1.5 font-[var(--font-sans)] text-[13px] font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
79
frontend/src/lib/shopping/ChecklistItem.svelte
Normal file
79
frontend/src/lib/shopping/ChecklistItem.svelte
Normal file
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
interface RecipeRef {
|
||||
id?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
listId: string;
|
||||
itemId: string;
|
||||
name: string;
|
||||
quantity: number | null;
|
||||
unit: string | null;
|
||||
isChecked: boolean;
|
||||
sourceRecipes: RecipeRef[];
|
||||
}
|
||||
|
||||
let { listId, itemId, name, quantity, unit, isChecked, sourceRecipes }: Props = $props();
|
||||
|
||||
let recipeLabel = $derived(
|
||||
sourceRecipes.length > 0
|
||||
? sourceRecipes
|
||||
.map((r) => r.name)
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: null
|
||||
);
|
||||
|
||||
let quantityLabel = $derived(
|
||||
quantity ? `${quantity}${unit ? ` ${unit}` : ''}` : null
|
||||
);
|
||||
</script>
|
||||
|
||||
<form method="POST" action="?/check" use:enhance class="group flex items-center gap-3 py-2">
|
||||
<input type="hidden" name="listId" value={listId} />
|
||||
<input type="hidden" name="itemId" value={itemId} />
|
||||
<input type="hidden" name="isChecked" value={!isChecked} />
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
aria-label="{isChecked ? 'Abhaken rückgängig' : 'Abhaken'}: {name}"
|
||||
class="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border
|
||||
{isChecked
|
||||
? 'border-[var(--green)] bg-[var(--green)] text-white'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--green-light)]'}"
|
||||
>
|
||||
{#if isChecked}
|
||||
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M2 6l3 3 5-5" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p
|
||||
class="font-[var(--font-sans)] text-[14px] {isChecked
|
||||
? 'text-[var(--color-text-muted)] line-through'
|
||||
: 'text-[var(--color-text)]'}"
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
{#if recipeLabel && !isChecked}
|
||||
<p class="truncate font-[var(--font-sans)] text-[11px] text-[var(--color-text-muted)]">
|
||||
Für: {recipeLabel}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if quantityLabel}
|
||||
<span
|
||||
class="flex-shrink-0 font-[var(--font-sans)] text-[13px] {isChecked
|
||||
? 'text-[var(--color-text-muted)]'
|
||||
: 'text-[var(--color-text)]'}"
|
||||
>
|
||||
{quantityLabel}
|
||||
</span>
|
||||
{/if}
|
||||
</form>
|
||||
62
frontend/src/lib/shopping/RecipeReferencePanel.svelte
Normal file
62
frontend/src/lib/shopping/RecipeReferencePanel.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { formatDayAbbr } from '$lib/planner/week';
|
||||
|
||||
interface Slot {
|
||||
slotDate?: string;
|
||||
recipe?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
slots: Slot[];
|
||||
filteredStaplesCount: number;
|
||||
}
|
||||
|
||||
let { slots, filteredStaplesCount }: Props = $props();
|
||||
|
||||
let filledSlots = $derived(slots.filter((s) => s.recipe));
|
||||
</script>
|
||||
|
||||
<aside class="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Rezepte dieser Woche
|
||||
</h2>
|
||||
<div class="mt-2 space-y-1.5">
|
||||
{#each filledSlots as slot}
|
||||
<a
|
||||
href="/recipes/{slot.recipe?.id}"
|
||||
class="flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-3 py-2 hover:border-[var(--green-light)]"
|
||||
>
|
||||
<span class="min-w-[28px] font-[var(--font-sans)] text-[11px] text-[var(--color-text-muted)]">
|
||||
{slot.slotDate ? formatDayAbbr(slot.slotDate, 'short') : ''}
|
||||
</span>
|
||||
<span class="flex-1 truncate font-[var(--font-sans)] text-[13px] font-medium text-[var(--color-text)]">
|
||||
{slot.recipe?.name}
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
{#if filledSlots.length === 0}
|
||||
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
|
||||
Keine Gerichte geplant.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if filteredStaplesCount > 0}
|
||||
<div class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-3 py-2">
|
||||
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
|
||||
{filteredStaplesCount} Grundzutaten automatisch ausgeblendet
|
||||
</p>
|
||||
<a
|
||||
href="/pantry"
|
||||
class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--green-dark)] hover:underline"
|
||||
>
|
||||
Vorrat bearbeiten
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
58
frontend/src/lib/shopping/ShoppingHeader.svelte
Normal file
58
frontend/src/lib/shopping/ShoppingHeader.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
interface Props {
|
||||
totalItems: number;
|
||||
checkedCount: number;
|
||||
generatedAt: string | null;
|
||||
weekPlanId: string | null;
|
||||
isPlanner: boolean;
|
||||
hasShoppingList: boolean;
|
||||
}
|
||||
|
||||
let { totalItems, checkedCount, generatedAt, weekPlanId, isPlanner, hasShoppingList }: Props = $props();
|
||||
|
||||
let remainingCount = $derived(totalItems - checkedCount);
|
||||
|
||||
let formattedTime = $derived(
|
||||
generatedAt
|
||||
? new Date(generatedAt).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: null
|
||||
);
|
||||
</script>
|
||||
|
||||
<header class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">
|
||||
Einkaufsliste
|
||||
</h1>
|
||||
|
||||
{#if isPlanner && weekPlanId}
|
||||
<form method="POST" action="?/generate" use:enhance>
|
||||
<input type="hidden" name="weekPlanId" value={weekPlanId} />
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-[var(--radius-md)] {hasShoppingList
|
||||
? 'border border-[var(--color-border)] text-[var(--color-text)] hover:bg-[var(--color-surface)]'
|
||||
: 'bg-[var(--green-dark)] text-white'} px-3 py-1.5 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)]"
|
||||
>
|
||||
{hasShoppingList ? 'Neu generieren' : 'Liste generieren'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if hasShoppingList}
|
||||
<p class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
{remainingCount} Artikel übrig · {checkedCount} abgehakt
|
||||
{#if formattedTime}
|
||||
<span class="ml-1">· erstellt {formattedTime}</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</header>
|
||||
86
frontend/src/routes/(app)/shopping/+page.server.ts
Normal file
86
frontend/src/routes/(app)/shopping/+page.server.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
import { getWeekStart } from '$lib/planner/week';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, url }) => {
|
||||
const weekParam = url.searchParams.get('week');
|
||||
const weekStart = weekParam ?? getWeekStart(new Date());
|
||||
|
||||
const api = apiClient(fetch);
|
||||
|
||||
const [shoppingResult, weekPlanResult] = await Promise.all([
|
||||
api.GET('/v1/shopping-list', {
|
||||
params: { query: { weekStart } }
|
||||
}),
|
||||
api.GET('/v1/week-plans', {
|
||||
params: { query: { weekStart } }
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
shoppingList: shoppingResult.data ?? null,
|
||||
weekPlan: weekPlanResult.data ?? null,
|
||||
weekStart
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
check: async ({ fetch, request }) => {
|
||||
const formData = await request.formData();
|
||||
const listId = formData.get('listId') as string;
|
||||
const itemId = formData.get('itemId') as string;
|
||||
const isChecked = formData.get('isChecked') === 'true';
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { data, error } = await api.PATCH('/v1/shopping-lists/{listId}/items/{itemId}', {
|
||||
params: { path: { listId, itemId } },
|
||||
body: { isChecked }
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
return { success: false, error: 'Status konnte nicht geändert werden.' };
|
||||
}
|
||||
|
||||
return { success: true, item: data };
|
||||
},
|
||||
|
||||
addItem: async ({ fetch, request }) => {
|
||||
const formData = await request.formData();
|
||||
const listId = formData.get('listId') as string;
|
||||
const customName = formData.get('customName') as string;
|
||||
const quantity = parseFloat(formData.get('quantity') as string) || 1;
|
||||
const unit = (formData.get('unit') as string) ?? '';
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { data, error } = await api.POST('/v1/shopping-lists/{id}/items', {
|
||||
params: { path: { id: listId } },
|
||||
body: { customName, quantity, unit }
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
return { success: false, error: 'Artikel konnte nicht hinzugefügt werden.' };
|
||||
}
|
||||
|
||||
return { success: true, item: data };
|
||||
},
|
||||
|
||||
generate: async ({ fetch, request, locals }) => {
|
||||
if (locals.benutzer?.rolle !== 'planer') {
|
||||
return { success: false, error: 'Keine Berechtigung.' };
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const weekPlanId = formData.get('weekPlanId') as string;
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { data, error } = await api.POST('/v1/week-plans/{id}/shopping-list', {
|
||||
params: { path: { id: weekPlanId } }
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
return { success: false, error: 'Einkaufsliste konnte nicht erstellt werden.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
@@ -1 +1,230 @@
|
||||
<h1 class="text-2xl font-medium p-6">Einkaufsliste</h1>
|
||||
<script lang="ts">
|
||||
import ShoppingHeader from '$lib/shopping/ShoppingHeader.svelte';
|
||||
import ChecklistItem from '$lib/shopping/ChecklistItem.svelte';
|
||||
import AddCustomItem from '$lib/shopping/AddCustomItem.svelte';
|
||||
import RecipeReferencePanel from '$lib/shopping/RecipeReferencePanel.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let shoppingList = $derived(data.shoppingList);
|
||||
let weekPlan = $derived(data.weekPlan);
|
||||
let isPlanner = $derived(data.benutzer?.rolle === 'planer');
|
||||
|
||||
let items = $derived(shoppingList?.items ?? []);
|
||||
let uncheckedItems = $derived(items.filter((i) => !i.isChecked));
|
||||
let checkedItems = $derived(items.filter((i) => i.isChecked));
|
||||
let totalItems = $derived(items.length);
|
||||
let checkedCount = $derived(checkedItems.length);
|
||||
|
||||
let slots = $derived(weekPlan?.slots ?? []);
|
||||
let filteredStaplesCount = $derived(shoppingList?.filteredStaplesCount ?? 0);
|
||||
let listId = $derived(shoppingList?.id ?? '');
|
||||
</script>
|
||||
|
||||
<!-- Mobile layout -->
|
||||
<div class="flex h-full flex-col lg:hidden">
|
||||
<header class="sticky top-0 z-10 border-b border-[var(--color-border)] bg-[var(--color-page)] px-4 py-3">
|
||||
<ShoppingHeader
|
||||
{totalItems}
|
||||
{checkedCount}
|
||||
generatedAt={shoppingList?.generatedAt ?? null}
|
||||
weekPlanId={weekPlan?.id ?? null}
|
||||
{isPlanner}
|
||||
hasShoppingList={!!shoppingList}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 overflow-y-auto px-4 py-3">
|
||||
{#if !weekPlan}
|
||||
<!-- Empty state: no week plan -->
|
||||
<div class="flex flex-col items-center justify-center py-16 text-center">
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
||||
Noch kein Wochenplan für diese Woche.
|
||||
</p>
|
||||
<a
|
||||
href="/planner"
|
||||
class="mt-3 font-[var(--font-sans)] text-[13px] font-medium text-[var(--green-dark)] hover:underline"
|
||||
>
|
||||
Zum Wochenplaner
|
||||
</a>
|
||||
</div>
|
||||
{:else if !shoppingList}
|
||||
<!-- Empty state: plan exists, no shopping list -->
|
||||
<div class="flex flex-col items-center justify-center py-16 text-center">
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
||||
Einkaufsliste noch nicht erstellt.
|
||||
</p>
|
||||
{#if isPlanner}
|
||||
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
Generiere die Liste aus dem Wochenplan.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
Der Planer muss die Liste zuerst erstellen.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Unchecked items -->
|
||||
{#if uncheckedItems.length > 0}
|
||||
<div class="divide-y divide-[var(--color-border)]">
|
||||
{#each uncheckedItems as item (item.id)}
|
||||
<ChecklistItem
|
||||
{listId}
|
||||
itemId={item.id ?? ''}
|
||||
name={item.name ?? ''}
|
||||
quantity={item.quantity ?? null}
|
||||
unit={item.unit ?? null}
|
||||
isChecked={false}
|
||||
sourceRecipes={item.sourceRecipes ?? []}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if totalItems > 0}
|
||||
<p class="py-4 text-center font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
||||
Alles erledigt!
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Add custom item -->
|
||||
<div class="mt-3">
|
||||
<AddCustomItem {listId} />
|
||||
</div>
|
||||
|
||||
<!-- Filtered staples info (mobile) -->
|
||||
{#if filteredStaplesCount > 0}
|
||||
<div class="mt-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2">
|
||||
<p class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
{filteredStaplesCount} Grundzutaten ausgeblendet ·
|
||||
<a href="/pantry" class="font-medium text-[var(--green-dark)] hover:underline">Vorrat bearbeiten</a>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Checked items -->
|
||||
{#if checkedItems.length > 0}
|
||||
<div class="mt-4">
|
||||
<p class="mb-1 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Abgehakt ({checkedCount})
|
||||
</p>
|
||||
<div class="divide-y divide-[var(--color-border)]">
|
||||
{#each checkedItems as item (item.id)}
|
||||
<ChecklistItem
|
||||
{listId}
|
||||
itemId={item.id ?? ''}
|
||||
name={item.name ?? ''}
|
||||
quantity={item.quantity ?? null}
|
||||
unit={item.unit ?? null}
|
||||
isChecked={true}
|
||||
sourceRecipes={item.sourceRecipes ?? []}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Desktop layout -->
|
||||
<div class="hidden h-screen lg:flex lg:flex-col">
|
||||
<header class="border-b border-[var(--color-border)] bg-[var(--color-page)] px-6 py-4">
|
||||
<ShoppingHeader
|
||||
{totalItems}
|
||||
{checkedCount}
|
||||
generatedAt={shoppingList?.generatedAt ?? null}
|
||||
weekPlanId={weekPlan?.id ?? null}
|
||||
{isPlanner}
|
||||
hasShoppingList={!!shoppingList}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Left panel: checklist -->
|
||||
<main class="flex-1 overflow-y-auto px-6 py-5">
|
||||
{#if !weekPlan}
|
||||
<div class="flex h-full flex-col items-center justify-center">
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
||||
Noch kein Wochenplan für diese Woche.
|
||||
</p>
|
||||
<a
|
||||
href="/planner"
|
||||
class="mt-3 font-[var(--font-sans)] text-[13px] font-medium text-[var(--green-dark)] hover:underline"
|
||||
>
|
||||
Zum Wochenplaner
|
||||
</a>
|
||||
</div>
|
||||
{:else if !shoppingList}
|
||||
<div class="flex h-full flex-col items-center justify-center">
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
||||
Einkaufsliste noch nicht erstellt.
|
||||
</p>
|
||||
{#if isPlanner}
|
||||
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
Generiere die Liste aus dem Wochenplan.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
Der Planer muss die Liste zuerst erstellen.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Unchecked items -->
|
||||
{#if uncheckedItems.length > 0}
|
||||
<div class="divide-y divide-[var(--color-border)]">
|
||||
{#each uncheckedItems as item (item.id)}
|
||||
<ChecklistItem
|
||||
{listId}
|
||||
itemId={item.id ?? ''}
|
||||
name={item.name ?? ''}
|
||||
quantity={item.quantity ?? null}
|
||||
unit={item.unit ?? null}
|
||||
isChecked={false}
|
||||
sourceRecipes={item.sourceRecipes ?? []}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if totalItems > 0}
|
||||
<p class="py-4 text-center font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
||||
Alles erledigt!
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Add custom item -->
|
||||
<div class="mt-4">
|
||||
<AddCustomItem {listId} />
|
||||
</div>
|
||||
|
||||
<!-- Checked items -->
|
||||
{#if checkedItems.length > 0}
|
||||
<div class="mt-6 border-t border-[var(--color-border)] pt-4">
|
||||
<p class="mb-1 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Abgehakt ({checkedCount})
|
||||
</p>
|
||||
<div class="divide-y divide-[var(--color-border)]">
|
||||
{#each checkedItems as item (item.id)}
|
||||
<ChecklistItem
|
||||
{listId}
|
||||
itemId={item.id ?? ''}
|
||||
name={item.name ?? ''}
|
||||
quantity={item.quantity ?? null}
|
||||
unit={item.unit ?? null}
|
||||
isChecked={true}
|
||||
sourceRecipes={item.sourceRecipes ?? []}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<!-- Right panel: recipe reference (desktop only) -->
|
||||
{#if weekPlan}
|
||||
<aside class="w-[280px] flex-shrink-0 overflow-y-auto border-l border-[var(--color-border)] bg-[var(--color-surface)] p-5">
|
||||
<RecipeReferencePanel {slots} {filteredStaplesCount} />
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user