munch-ease-frontend/src/components/meals/EditMealPage.vue
2025-11-02 19:41:33 +11:00

459 lines
13 KiB
Vue

<template>
<div class="container">
<div class="fields">
<date-picker
:date="meal.suggestedDate ?? new Date()"
@date-selected="selectDate"
/>
<div class="members-list">
Cooked by
<member-list
:people="meal.chefs"
@remove="(p) => removePerson('chefs', p)"
@add="(p) => addPerson('chefs', p)"
/>
for
<member-list
:people="meal.consumers"
@remove="(p) => removePerson('consumers', p)"
@add="(p) => addPerson('consumers', p)"
/>, with
<member-list
:people="meal.cleanup"
@remove="(p) => removePerson('cleanup', p)"
@add="(p) => addPerson('cleanup', p)"
/>
on cleanup.
</div>
</div>
<div class="recipes">
<h2>Recipes</h2>
<ul v-if="meal.recipes.length">
<li
v-for="mealRecipe in meal.recipes"
:key="mealRecipe.recipe?.id ?? mealRecipe.recipeId"
>
<div
v-if="mealRecipe.recipe"
class="saved-recipe"
>
<p class="recipe-card">
<recipe-card :recipe="mealRecipe.recipe" />
</p>
<p class="servings">
<input
v-model="mealRecipe.servings"
type="number"
min="1"
>
<small><em>servings</em></small>
</p>
<input
type="checkbox"
class="show-ingredient-checkbox"
:checked="showIngredient(mealRecipe)"
>
<label
for="show-ingredients"
@click="showIngredient(mealRecipe, !showIngredient(mealRecipe))"
>
<img
class="icon"
:src="showIngredientsIcon"
>
</label>
<button
class="icon-button"
@click="removeRecipe(mealRecipe)"
>
<img
class="icon"
:src="trash"
>
</button>
</div>
<div v-if="showIngredient(mealRecipe)">
<ul>
<li
v-for="ingredient in scaleIngredients(mealRecipe)"
:key="ingredient.id"
class="saved-ingredient"
>
<CompactParsedIngredient :ingredient="ingredient" />
</li>
</ul>
</div>
</li>
</ul>
<div v-else>
<p>Add some recipes using the search box</p>
</div>
<div class="fields">
<recipe-search-box @select-recipe="selectRecipe" />
</div>
</div>
<div class="ingredients">
<h2>Sides & Additional Ingredients</h2>
<editable-ingredients-panel
:ingredients="meal.extraIngredients"
@on-add="addIngredient"
@on-delete="deleteIngredient"
@on-update-ingredient="updateIngredient"
@on-update-line="updateIngredientLine"
@on-editing="onEditAdditionalIngredients"
/>
</div>
<button @click="onSaveMeal">
Save
</button>
<p v-if="meal.purchaseDate">
<em>Purchased {{ ago(meal.purchaseDate) }}</em>
</p>
</div>
</template>
<script setup lang="ts">
import { reactive, onBeforeMount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { toMealEdit } from '@/router/links'
import { getMeal, saveMeal, getRecipe } from '@/api/sdk'
import { toMealInput } from '@/domain/decoders'
import { currentUser } from '@/api/auth'
import { useAlert } from '@/composables/useAlert'
import { parseRouteId } from '@/router/helpers'
import type { Ingredient, Meal, MealRecipe } from '@/domain/types'
import { listMembers } from '@/api/households'
import { selectMyMember } from '@/domain/members'
import { ago } from '@/dateformats'
import RecipeSearchBox from '@/components/recipes/RecipeSearchBox.vue'
import RecipeCard from '@/components/recipes/RecipeCard.vue'
import DatePicker from './DatePicker.vue'
import EditableIngredientsPanel from '../ingredients/EditableIngredientsPanel.vue'
import MemberList from './MemberList.vue'
import CompactParsedIngredient from '../ingredients/CompactParsedIngredient.vue'
const showIngredientsIcon = new URL('@/assets/show-ingredients.svg', import.meta.url).toString()
const trash = new URL('@/assets/trash.svg', import.meta.url).toString()
import type { MemberRef } from '@/domain/types'
function addPersonIfNotExists<T extends { id: number }>(list: T[], person: T | null | undefined) {
if (!person) return
if (!list.find((p) => p.id === person.id)) {
list.push(person)
}
}
const route = useRoute()
const router = useRouter()
const { show: showAlert } = useAlert()
// No child refs; parent maintains source of truth for lines
type PeopleKey = 'chefs' | 'consumers' | 'cleanup'
// Track which ingredient objects have unparsed edits so we only parse what changed
const dirtyLines = new Map<Ingredient, string>()
const meal = reactive<Meal>({
id: -1,
suggestedDate: new Date(),
consumedDate: null,
purchaseDate: null,
recipes: [],
extraIngredients: [],
chefs: [],
consumers: [],
cleanup: [],
})
onBeforeMount(async () => {
const id = parseRouteId(route.params.id)
if (id !== null) {
const loaded = await getMeal(id)
Object.assign(meal, loaded)
} else {
const [user, members] = await Promise.all([currentUser(), listMembers()])
const fallback = Array.isArray(members) && members.length > 0 ? { id: members[0]!.id, displayName: members[0]!.displayName } : null
const me = selectMyMember(members, user) ?? fallback
if (me) {
meal.chefs = [me]
meal.consumers = [me]
meal.cleanup = [me]
}
}
})
function selectDate(date: Date) {
meal.suggestedDate = date
}
function removeRecipe(mealRecipe: MealRecipe) {
if (
confirm(
`Are you sure you want to remove ${mealRecipe.servings} servings of ${mealRecipe.recipe?.name ?? 'this recipe'} from this meal?`
)
) {
meal.recipes = meal.recipes.filter((r) => r !== mealRecipe)
}
}
function addIngredient() {
meal.extraIngredients = [{ id: -1, name: '', line: '', unit: 'Items', quantity: 0, preparation: '', productId: null, recipeId: null, mealId: null, product: null }, ...meal.extraIngredients]
}
function deleteIngredient(ingredient: Ingredient) {
meal.extraIngredients = meal.extraIngredients.filter((i) => i !== ingredient)
}
function updateIngredient(ingredient: Ingredient, newIngredient: Ingredient) {
meal.extraIngredients = meal.extraIngredients.map((i) => (i === ingredient ? newIngredient : i))
// Clear dirty status for this row (was parsed and replaced)
if (dirtyLines.has(ingredient)) dirtyLines.delete(ingredient)
}
function updateIngredientLine(ingredient: Ingredient, newLine: string) {
// Update the raw line immediately so Save has the latest text
const idx = meal.extraIngredients.indexOf(ingredient)
if (idx >= 0) {
// mutate in place to preserve object identity (used as dirtyLines key)
const target = meal.extraIngredients[idx]
if (target) target.line = newLine
} else {
// fallback (shouldn't generally happen)
meal.extraIngredients = meal.extraIngredients.map((i) => (i === ingredient ? { ...i, line: newLine } : i))
}
// Mark as dirty to parse later (on save) if needed
dirtyLines.set(ingredient, newLine)
}
function removePerson(list: PeopleKey, person: MemberRef) {
meal[list] = meal[list].filter((p) => p.id !== person.id)
}
function addPerson(list: PeopleKey, person: MemberRef) {
addPersonIfNotExists(meal[list], person)
}
async function selectRecipe(recipe: { id: number | string }) {
// Refetch to get additional details
const slug = typeof route.params.householdSlug === 'string' ? route.params.householdSlug : ''
const r = await getRecipe(slug, recipe.id)
meal.recipes.push({ recipe: r, recipeId: r.id, mealId: meal.id, servings: r.serves })
}
async function onEditAdditionalIngredients(editing: boolean) {
if (editing && meal.extraIngredients.length === 0) {
addIngredient()
} else {
meal.extraIngredients = meal.extraIngredients.filter((i: Ingredient) => !!i.line)
}
}
const showMap = reactive<Record<string, boolean>>({})
function showIngredient(mealRecipe: MealRecipe, value?: boolean): boolean {
const index = meal.recipes.indexOf(mealRecipe)
const key = `${mealRecipe.recipe?.id ?? 'unknown'}-${index}`
if (value === undefined) {
return !!showMap[key]
}
showMap[key] = value
return value
}
function scaleIngredients(mealRecipe: MealRecipe) {
const ing = mealRecipe.recipe?.ingredients ?? []
const serves = mealRecipe.recipe?.serves ?? 1
return ing.map((i) => {
return {
...i,
quantity: (i.quantity * mealRecipe.servings) / serves,
}
})
}
async function onSaveMeal() {
try {
// Blur any focused input so its change handlers run
if (typeof document !== 'undefined' && document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
// 1) Drop any empty-line rows and clear their dirty flags
const nonEmpty: Ingredient[] = []
for (const ing of meal.extraIngredients) {
const line = typeof ing.line === 'string' ? ing.line.trim() : ''
if (line.length === 0) {
// also clear dirty if present
if (dirtyLines.has(ing)) dirtyLines.delete(ing)
continue
}
nonEmpty.push(ing)
}
meal.extraIngredients = nonEmpty
// 2) Build list of only the dirty lines that still exist in the array
const dirtyEntries: Array<{ ing: Ingredient; line: string }> = []
for (const [ing, line] of dirtyLines.entries()) {
// only consider ingredients still present
if (meal.extraIngredients.includes(ing)) {
const t = typeof line === 'string' ? line.trim() : ''
if (t.length > 0) dirtyEntries.push({ ing, line: t })
}
}
// 3) Parse only dirty lines
if (dirtyEntries.length > 0) {
const lines = dirtyEntries.map((e) => e.line)
const parsed = await (await import('@/api/sdk')).parseIngredients(lines)
// Replace corresponding rows by identity
parsed.forEach((p, idx) => {
const target = dirtyEntries[idx]?.ing
if (!target) return
const i = meal.extraIngredients.indexOf(target)
if (i >= 0) meal.extraIngredients.splice(i, 1, p)
// Clear dirty marker for this ingredient object
dirtyLines.delete(target)
})
}
// Final safety: drop any zero-quantity items (should be rare post-parse)
meal.extraIngredients = meal.extraIngredients.filter((i: Ingredient) => (typeof i.quantity === 'number' ? i.quantity > 0 : true))
const saved = await saveMeal(toMealInput(meal))
if (saved && saved.id >= 0) {
Object.assign(meal, saved)
router.push(toMealEdit(saved.id))
showAlert({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' })
return
}
showAlert({
heading: 'Error saving meal',
message: 'An unknown error occurred while saving the meal',
type: 'error',
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save meal'
showAlert({ heading: 'Error saving meal', message, type: 'error' })
}
}
</script>
<style scoped>
img.icon {
width: 2em;
height: 2em;
}
.members-list {
text-align: left;
padding: 1ex 2em;
}
.members-list p {
margin: 0;
padding-bottom: 1em;
}
.member-list li {
display: inline-block;
padding-right: 1em;
}
.container {
margin: 1em auto;
}
ul {
list-style-type: none;
margin: 0;
padding: 0;
}
li {
padding-bottom: 0.5vh;
}
.saved-ingredient {
display: flex;
justify-content: space-between;
align-items: center;
}
.saved-recipe {
display: flex;
justify-content: space-between;
align-items: center;
}
.saved-ingredient p {
flex: 1;
margin: 0;
margin-right: 1em;
}
.saved-recipe button:hover {
background: #eee;
}
.saved-recipe .recipe-card {
margin: 0;
}
.recipe-card {
flex: 1;
}
.servings {
display: inline-block;
margin-right: 1em;
}
.servings input {
width: 3em;
border: none;
border-bottom: 1px solid #000;
text-align: center;
font-size: large;
font-style: italic;
}
.icon-button {
background: none;
border: none;
cursor: pointer;
border-radius: 1em;
padding: 0.5em;
}
.icon-button:hover {
/* Invert the colors of the trash icon */
filter: invert(1);
}
.show-ingredient-checkbox {
display: none;
}
.show-ingredient-checkbox+label {
cursor: pointer;
background-color: #fff;
padding: 0.5em;
border-radius: 1em;
}
.show-ingredient-checkbox+label:hover {
background-color: #eee;
}
.show-ingredient-checkbox:checked+label {
filter: invert(1);
}
</style>