This commit is contained in:
smilerz 2021-10-26 19:56:44 -05:00
parent 5c9f5e0e1a
commit 7c598720d0
9 changed files with 177 additions and 765 deletions

View File

@ -479,8 +479,7 @@ class ShoppingPreferenceForm(forms.ModelForm):
model = UserPreference model = UserPreference
fields = ( fields = (
'shopping_share', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'mealplan_autoexclude_onhand', 'default_delay'
'mealplan_autoinclude_related', 'default_delay', 'filter_to_supermarket'
) )
help_texts = { help_texts = {
@ -491,18 +490,14 @@ class ShoppingPreferenceForm(forms.ModelForm):
), ),
'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'), 'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'),
'mealplan_autoexclude_onhand': _('When automatically adding a meal plan to the shopping list, exclude ingredients that are on hand.'), 'mealplan_autoexclude_onhand': _('When automatically adding a meal plan to the shopping list, exclude ingredients that are on hand.'),
'mealplan_autoinclude_related': _('When automatically adding a meal plan to the shopping list, include all related recipes.'),
'default_delay': _('Default number of hours to delay a shopping list entry.'), 'default_delay': _('Default number of hours to delay a shopping list entry.'),
'filter_to_supermarket': _('Filter shopping list to only include supermarket categories.'),
} }
labels = { labels = {
'shopping_share': _('Share Shopping List'), 'shopping_share': _('Share Shopping List'),
'shopping_auto_sync': _('Autosync'), 'shopping_auto_sync': _('Autosync'),
'mealplan_autoadd_shopping': _('Auto Add Meal Plan'), 'mealplan_autoadd_shopping': _('Auto Add Meal Plan'),
'mealplan_autoexclude_onhand': _('Exclude On Hand'), 'mealplan_autoexclude_onhand': _('Exclude On Hand'),
'mealplan_autoinclude_related': _('Include Related'),
'default_delay': _('Default Delay Hours'), 'default_delay': _('Default Delay Hours'),
'filter_to_supermarket': _('Filter to Supermarket'),
} }
widgets = { widgets = {

View File

@ -23,11 +23,6 @@ def shopping_helper(qs, request):
supermarket_categories = SupermarketCategoryRelation.objects.filter(supermarket=supermarket, category=OuterRef('food__supermarket_category')) supermarket_categories = SupermarketCategoryRelation.objects.filter(supermarket=supermarket, category=OuterRef('food__supermarket_category'))
qs = qs.annotate(supermarket_order=Coalesce(Subquery(supermarket_categories.values('order')), Value(9999))) qs = qs.annotate(supermarket_order=Coalesce(Subquery(supermarket_categories.values('order')), Value(9999)))
supermarket_order = ['supermarket_order'] + supermarket_order supermarket_order = ['supermarket_order'] + supermarket_order
# if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']:
# qs = qs.annotate(recipe_notes=ArrayAgg('list_recipe__recipe__steps__ingredients__note', filter=Q(list_recipe__recipe__steps__ingredients__food=F('food_id'))))
# qs = qs.annotate(meal_notes=ArrayAgg('list_recipe__mealplan__note', distinct=True, filter=Q(list_recipe__mealplan__note__isnull=False)))
# else:
# pass # ignore adding notes when running sqlite? or do some ugly contruction?
if checked in ['false', 0, '0']: if checked in ['false', 0, '0']:
qs = qs.filter(checked=False) qs = qs.filter(checked=False)
elif checked in ['true', 1, '1']: elif checked in ['true', 1, '1']:
@ -39,4 +34,4 @@ def shopping_helper(qs, request):
qs = qs.filter(Q(checked=False) | Q(completed_at__gte=week_ago)) qs = qs.filter(Q(checked=False) | Q(completed_at__gte=week_ago))
supermarket_order = ['checked'] + supermarket_order supermarket_order = ['checked'] + supermarket_order
return qs.order_by(*supermarket_order).select_related('unit', 'food', 'list_recipe__mealplan', 'list_recipe__recipe') return qs.order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe')

View File

@ -17,6 +17,7 @@ from django.db import IntegrityError, models
from django.db.models import Index, ProtectedError, Q, Subquery from django.db.models import Index, ProtectedError, Q, Subquery
from django.db.models.fields.related import ManyToManyField from django.db.models.fields.related import ManyToManyField
from django.db.models.functions import Substr from django.db.models.functions import Substr
from django.db.transaction import atomic
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django_prometheus.models import ExportModelOperationsMixin from django_prometheus.models import ExportModelOperationsMixin
@ -328,6 +329,7 @@ class UserPreference(models.Model, PermissionModelMixin):
mealplan_autoadd_shopping = models.BooleanField(default=False) mealplan_autoadd_shopping = models.BooleanField(default=False)
mealplan_autoexclude_onhand = models.BooleanField(default=True) mealplan_autoexclude_onhand = models.BooleanField(default=True)
mealplan_autoinclude_related = models.BooleanField(default=True) mealplan_autoinclude_related = models.BooleanField(default=True)
default_delay = models.IntegerField(default=4)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True) space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True)
@ -823,7 +825,7 @@ class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), mod
def get_owner(self): def get_owner(self):
try: try:
return getattr(self.entries.first(), 'created_by', None) or getattr(self.shoppinglist_set.first(), 'created_by', None) return self.entries.first().created_by or self.shoppinglist_set.first().created_by
except AttributeError: except AttributeError:
return None return None
@ -839,11 +841,13 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model
created_by = models.ForeignKey(User, on_delete=models.CASCADE) created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
completed_at = models.DateTimeField(null=True, blank=True) completed_at = models.DateTimeField(null=True, blank=True)
delay_until = models.DateTimeField(null=True, blank=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE) space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space') objects = ScopedManager(space='space')
@ classmethod @classmethod
@atomic
def list_from_recipe(self, list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None): def list_from_recipe(self, list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None):
""" """
Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe
@ -853,22 +857,51 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model
:param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted :param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted
:param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used :param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used
""" """
# TODO cascade to associated recipes # TODO cascade to related recipes
try: r = recipe or getattr(mealplan, 'recipe', None) or getattr(list_recipe, 'recipe', None)
r = recipe or mealplan.recipe if not r:
except AttributeError:
raise ValueError(_("You must supply a recipe or mealplan")) raise ValueError(_("You must supply a recipe or mealplan"))
created_by = created_by or getattr(mealplan, 'created_by', None) created_by = created_by or getattr(mealplan, 'created_by', None) or getattr(list_recipe, 'created_by', None)
if not created_by: if not created_by:
raise ValueError(_("You must supply a created_by")) raise ValueError(_("You must supply a created_by"))
servings = servings or getattr(mealplan, 'servings', 1.0) if type(servings) not in [int, float]:
if ingredients: servings = getattr(mealplan, 'servings', 1.0)
shared_users = list(created_by.get_shopping_share())
shared_users.append(created_by)
if list_recipe:
created = False
else:
list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings)
created = True
if servings == 0 and not created:
list_recipe.delete()
return []
elif ingredients:
ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space) ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space)
else: else:
ingredients = Ingredient.objects.filter(step__recipe=r, space=space) ingredients = Ingredient.objects.filter(step__recipe=r, space=space)
list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings) existing_list = ShoppingListEntry.objects.filter(list_recipe=list_recipe)
# delete shopping list entries not included in ingredients
existing_list.exclude(ingredient__in=ingredients).delete()
# add shopping list entries that did not previously exist
add_ingredients = set(ingredients.values_list('id', flat=True)) - set(existing_list.values_list('ingredient__id', flat=True))
add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
# if servings have changed, update the ShoppingListRecipe and existing Entrys
if servings <= 0:
servings = 1
servings_factor = servings / r.servings
if not created and list_recipe.servings != servings:
update_ingredients = set(ingredients.values_list('id', flat=True)) - set(add_ingredients.values_list('id', flat=True))
for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients):
sle.amount = sle.ingredient.amount * Decimal(servings_factor)
sle.save()
# add any missing Entrys
shoppinglist = [ shoppinglist = [
ShoppingListEntry( ShoppingListEntry(
list_recipe=list_recipe, list_recipe=list_recipe,

View File

@ -162,7 +162,7 @@ class UserPreferenceSerializer(serializers.ModelSerializer):
fields = ( fields = (
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'user', 'theme', 'nav_color', 'default_unit', 'default_page',
'search_style', 'show_recent', 'plan_share', 'ingredient_decimals', 'search_style', 'show_recent', 'plan_share', 'ingredient_decimals',
'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_ignore_default' 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_ignore_default', 'default_delay'
) )
@ -392,7 +392,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
model = Food model = Food
fields = ( fields = (
'id', 'name', 'description', 'shopping', 'recipe', 'ignore_shopping', 'supermarket_category', 'id', 'name', 'description', 'shopping', 'recipe', 'ignore_shopping', 'supermarket_category',
'image', 'parent', 'numchild', 'numrecipe', 'on_hand', 'inherit', 'ignore_inherit' 'image', 'parent', 'numchild', 'numrecipe', 'on_hand', 'inherit', 'ignore_inherit',
) )
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe') read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
@ -634,8 +634,26 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
mealplan_note = serializers.ReadOnlyField(source='mealplan.note') mealplan_note = serializers.ReadOnlyField(source='mealplan.note')
servings = CustomDecimalField() servings = CustomDecimalField()
def get_note_markdown(self, obj): def get_name(self, obj):
return obj.mealplan and markdown(obj.mealplan.note) if not isinstance(value := obj.servings, Decimal):
value = Decimal(value)
value = value.quantize(Decimal(1)) if value == value.to_integral() else value.normalize() # strips trailing zero
return (
obj.name
or getattr(obj.mealplan, 'title', None)
or (d := getattr(obj.mealplan, 'date', None)) and ': '.join([obj.mealplan.recipe.name, str(d)])
or obj.recipe.name
) + f' ({value:.2g})'
def update(self, instance, validated_data):
if 'servings' in validated_data:
ShoppingListEntry.list_from_recipe(
list_recipe=instance,
servings=validated_data['servings'],
created_by=self.context['request'].user,
space=self.context['request'].space
)
return super().update(instance, validated_data)
class Meta: class Meta:
model = ShoppingListRecipe model = ShoppingListRecipe
@ -649,7 +667,8 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
ingredient_note = serializers.ReadOnlyField(source='ingredient.note') ingredient_note = serializers.ReadOnlyField(source='ingredient.note')
recipe_mealplan = ShoppingListRecipeSerializer(source='list_recipe', read_only=True) recipe_mealplan = ShoppingListRecipeSerializer(source='list_recipe', read_only=True)
amount = CustomDecimalField() amount = CustomDecimalField()
created_by = UserNameSerializer() created_by = UserNameSerializer(read_only=True)
completed_at = serializers.DateTimeField(allow_null=True)
def get_fields(self, *args, **kwargs): def get_fields(self, *args, **kwargs):
fields = super().get_fields(*args, **kwargs) fields = super().get_fields(*args, **kwargs)
@ -684,7 +703,8 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
data['completed_at'] = None data['completed_at'] = None
else: else:
# otherwise don't write anything # otherwise don't write anything
del data['completed_at'] if 'completed_at' in data:
del data['completed_at']
############################################################ ############################################################
# temporary while old and new shopping lists are both in use # temporary while old and new shopping lists are both in use
@ -711,9 +731,9 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
model = ShoppingListEntry model = ShoppingListEntry
fields = ( fields = (
'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked', 'recipe_mealplan', 'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked', 'recipe_mealplan',
'created_by', 'created_at', 'completed_at' 'created_by', 'created_at', 'completed_at', 'delay_until'
) )
read_only_fields = ('id', 'list_recipe', 'created_by', 'created_at',) read_only_fields = ('id', 'created_by', 'created_at',)
# TODO deprecate # TODO deprecate

View File

@ -385,7 +385,6 @@ def user_settings(request):
up.mealplan_autoexclude_onhand = shopping_form.cleaned_data['mealplan_autoexclude_onhand'] up.mealplan_autoexclude_onhand = shopping_form.cleaned_data['mealplan_autoexclude_onhand']
up.mealplan_autoinclude_related = shopping_form.cleaned_data['mealplan_autoinclude_related'] up.mealplan_autoinclude_related = shopping_form.cleaned_data['mealplan_autoinclude_related']
up.shopping_auto_sync = shopping_form.cleaned_data['shopping_auto_sync'] up.shopping_auto_sync = shopping_form.cleaned_data['shopping_auto_sync']
up.filter_to_supermarket = shopping_form.cleaned_data['filter_to_supermarket']
up.default_delay = shopping_form.cleaned_data['default_delay'] up.default_delay = shopping_form.cleaned_data['default_delay']
if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL: if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL:
up.shopping_auto_sync = settings.SHOPPING_MIN_AUTOSYNC_INTERVAL up.shopping_auto_sync = settings.SHOPPING_MIN_AUTOSYNC_INTERVAL

View File

@ -1,460 +0,0 @@
<template>
<!-- add alert at top if offline -->
<!-- get autosync time from preferences and put fetching checked items on timer -->
<!-- allow reordering or items -->
<div id="app" style="margin-bottom: 4vh">
<div class="row float-top">
<div class="offset-md-10 col-md-2 no-gutter text-right">
<b-button variant="link" class="px-0">
<i class="btn fas fa-plus-circle text-muted fa-lg" @click="startAction({ action: 'new' })" />
</b-button>
<b-button variant="link" id="id_filters_button" class="px-0">
<i class="btn fas fa-filter text-decoration-none text-muted fa-lg" />
</b-button>
</div>
</div>
<b-tabs content-class="mt-3">
<b-tab :title="$t('ShoppingList')" active>
<template #title> <b-spinner v-if="loading" type="border" small></b-spinner> {{ $t("ShoppingList") }} </template>
<div class="row">
<div class="col col-md-12">
<!-- TODO add spinner -->
<div role="tablist" v-if="items && items.length > 0">
<!-- WARNING: all data in the table should be considered read-only, don't change any data through table bindings -->
<div v-for="(done, x) in Sections" :key="x">
<div v-if="x == 'true'">
<hr />
<hr />
<h4>{{ $t("Completed") }}</h4>
</div>
<div v-for="(s, i) in done" :key="i">
<h5>
<b-button
class="btn btn-lg text-decoration-none text-dark px-1 py-0 border-0"
variant="link"
data-toggle="collapse"
:href="'#section-' + sectionID(x, i)"
:aria-expanded="'true' ? x == 'false' : 'true'"
>
<i class="fa fa-chevron-right rotate" />
{{ i }}
</b-button>
</h5>
<div class="collapse" :id="'section-' + sectionID(x, i)" visible role="tabpanel" :class="{ show: x == 'false' }">
<!-- passing an array of values to the table grouped by Food -->
<div v-for="(entries, x) in Object.entries(s)" :key="x">
<ShoppingLineItem
:entries="entries[1]"
:groupby="group_by"
@open-context-menu="openContextMenu"
@toggle-checkbox="toggleChecked"
></ShoppingLineItem>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</b-tab>
<b-tab :title="$t('Settings')">
These are the settings <br />-sort supermarket categories<br />
-add supermarket categories<br />
- add supermarkets autosync time<br />
autosync on/off<br />
always restrict supermarket to categories?<br />
when restricted or filterd - give visual indication<br />
how long to defer shopping - default tomorrow
</b-tab>
</b-tabs>
<b-popover target="id_filters_button" triggers="click" placement="bottomleft" :title="$t('Filters')">
<div>
<b-form-group v-bind:label="$t('GroupBy')" label-for="popover-input-1" label-cols="6" class="mb-3">
<b-form-select v-model="group_by" :options="group_by_choices" size="sm"></b-form-select>
</b-form-group>
<b-form-group v-bind:label="$t('Supermarket')" label-for="popover-input-2" label-cols="6" class="mb-3">
<b-form-select v-model="selected_supermarket" :options="supermarkets" text-field="name" value-field="id" size="sm"></b-form-select>
</b-form-group>
<b-form-group v-bind:label="$t('ShowUncategorizedFood')" label-for="popover-input-2" label-cols="6" class="mb-3 text-nowrap">
<b-form-checkbox v-model="show_undefined_categories"></b-form-checkbox>
</b-form-group>
<b-form-group v-bind:label="$t('SupermarketCategoriesOnly')" label-for="popover-input-3" label-cols="6" class="mb-3">
<b-form-checkbox v-model="supermarket_categories_only"></b-form-checkbox>
</b-form-group>
</div>
<div class="row" style="margin-top: 1vh;min-width:300px">
<div class="col-12" style="text-align: right;">
<b-button size="sm" variant="secondary" style="margin-right:20px" @click="$root.$emit('bv::hide::popover')">{{ $t("Close") }} </b-button>
</div>
</div>
</b-popover>
<ContextMenu ref="menu">
<template #menu="{ contextData }">
<ContextMenuItem
@click="
moveEntry($event, contextData)
$refs.menu.close()
"
>
<b-form-group label-cols="10" content-cols="2" class="text-nowrap m-0 mr-2">
<template #label>
<a class="dropdown-item p-2" href="#"><i class="fas fa-cubes"></i> {{ $t("MoveCategory", { category: categoryName(shopcat) }) }}</a>
</template>
<div @click.prevent.stop @mouseup.prevent.stop>
<b-form-select class="mt-2 border-0" :options="shopping_categories" text-field="name" value-field="id" v-model="shopcat"></b-form-select>
</div>
</b-form-group>
</ContextMenuItem>
<ContextMenuItem
@click="
$refs.menu.close()
onHand(contextData)
"
>
<a class="dropdown-item p-2" href="#"><i class="fas fa-clipboard-check"></i> {{ $t("OnHand") }}</a>
</ContextMenuItem>
<ContextMenuItem
@click="
$refs.menu.close()
delayThis(contextData)
"
>
<b-form-group label-cols="10" content-cols="2" class="text-nowrap m-0 mr-2">
<template #label>
<a class="dropdown-item p-2" href="#"><i class="far fa-hourglass"></i> {{ $t("DelayFor", { hours: delay }) }}</a>
</template>
<b-form-input class="mt-2" min="0" type="number" v-model="delay"></b-form-input>
</b-form-group>
</ContextMenuItem>
<ContextMenuItem
@click="
$refs.menu.close()
ignoreThis(contextData)
"
>
<a class="dropdown-item p-2" href="#"><i class="fas fa-ban"></i> {{ $t("IgnoreThis", { food: foodName(contextData) }) }}</a>
</ContextMenuItem>
<ContextMenuItem
@click="
$refs.menu.close()
deleteThis(contextData)
"
>
<a class="dropdown-item p-2 text-danger" href="#"><i class="fas fa-trash"></i> {{ $t("Delete") }}</a>
</ContextMenuItem>
</template>
</ContextMenu>
</div>
</template>
<script>
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
import ContextMenu from "@/components/ContextMenu/ContextMenu"
import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
import ShoppingLineItem from "@/components/ShoppingLineItem"
import { ApiMixin, getUserPreference } from "@/utils/utils"
import { ApiApiFactory } from "@/utils/openapi/api"
import { StandardToasts, makeToast } from "@/utils/utils"
Vue.use(BootstrapVue)
export default {
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
// or i'm capturing it incorrectly
name: "ChecklistView",
mixins: [ApiMixin],
components: { ContextMenu, ContextMenuItem, ShoppingLineItem },
data() {
return {
// this.Models and this.Actions inherited from ApiMixin
items: [],
group_by: "category", //TODO create setting to switch group_by
group_by_choices: ["created_by", "category", "recipe"],
supermarkets: [],
shopping_categories: [],
selected_supermarket: undefined,
show_undefined_categories: true,
supermarket_categories_only: false,
shopcat: null,
delay: 0, // user default
show_modal: false,
fields: ["checked", "amount", "category", "unit", "food", "recipe", "details"],
loading: true,
}
},
computed: {
Sections() {
function getKey(item, group_by, x) {
switch (group_by) {
case "category":
return item?.food?.supermarket_category?.name ?? x
case "created_by":
return item?.created_by?.username ?? x
case "recipe":
return item?.recipe_mealplan?.recipe_name ?? x
}
}
const groups = {}
this.items
let shopping_list = this.items
if (this.selected_supermarket && this.supermarket_categories_only) {
let shopping_categories = this.supermarkets // category IDs configured on supermarket
.map((x) => x.category_to_supermarket)
.flat()
.map((x) => x.category.id)
shopping_list = shopping_list.filter((x) => shopping_categories.includes(x?.food?.supermarket_category?.id))
} else if (!this.show_undefined_categories) {
shopping_list = shopping_list.filter((x) => x?.food?.supermarket_category)
}
shopping_list.forEach((item) => {
let key = getKey(item, this.group_by, this.$t("Undefined"))
// first level of dict is done/not done
if (!groups[item.checked]) groups[item.checked] = {}
// second level of dict is this.group_by selection
if (!groups[item.checked][key]) groups[item.checked][key] = {}
// third level of dict is the food
if (groups[item.checked][key][item.food.name]) {
groups[item.checked][key][item.food.name].push(item)
} else {
groups[item.checked][key][item.food.name] = [item]
}
})
console.log(groups)
return groups
},
Fields() {
switch (this.group_by) {
case "category":
return this.fields.filter((x) => x !== "category")
case "recipe":
return this.fields.filter((x) => x.key !== "recipe_mealplan.name")
}
return this.fields
},
},
watch: {
selected_supermarket(newVal, oldVal) {
this.getItems()
},
},
mounted() {
// value is passed from lists.py
this.getItems()
this.getSupermarkets()
this.getShoppingCategories()
this.hours = getUserPreference("shopping_delay") || 1
},
methods: {
// this.genericAPI inherited from ApiMixin
getItems: function(params = {}) {
this.loading = true
params = {
supermarket: this.selected_supermarket,
}
params.options = { query: { recent: 1 } } // returns extended values in API response
this.genericAPI(this.Models.SHOPPING_LIST, this.Actions.LIST, params)
.then((results) => {
console.log(results.data)
if (results.data?.length) {
this.items = results.data
} else {
console.log("no data returned")
}
this.loading = false
})
.catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
})
},
getSupermarkets: function() {
this.genericAPI(this.Models.SUPERMARKET, this.Actions.LIST).then((result) => {
this.supermarkets = result.data
})
},
getShoppingCategories: function() {
this.genericAPI(this.Models.SHOPPING_CATEGORY, this.Actions.LIST).then((result) => {
this.shopping_categories = result.data
})
},
getThis: function(id) {
return this.genericAPI(this.Models.SHOPPING_CATEGORY, this.Actions.FETCH, { id: id })
},
saveThis: function(thisItem, toast = true) {
if (!thisItem?.id) {
// if there is no item id assume it's a new item
this.genericAPI(this.Models.SHOPPING_CATEGORY, this.Actions.CREATE, thisItem)
.then((result) => {
// this.items = result.data - refresh the list here
if (toast) {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
}
})
.catch((err) => {
console.log(err)
if (toast) {
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
}
})
} else {
this.genericAPI(this.Models.SHOPPING_CATEGORY, this.Actions.UPDATE, thisItem)
.then((result) => {
// this.refreshThis(thisItem.id) refresh the list here
if (toast) {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
}
})
.catch((err) => {
console.log(err, err.response)
if (toast) {
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
}
})
}
},
getRecipe: function(item) {
// change to get pop up card? maybe same for unit and food?
},
deleteThis: function(id) {
this.genericAPI(this.Models.SHOPPING_CATEGORY, this.Actions.DELETE, { id: id })
.then((result) => {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
})
.catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
})
},
// formatAmount: function(value) {
// return value[1][0].amount
// },
// formatCategory: function(value) {
// return value[1][0]?.name ?? this.$t("Undefined")
// },
// formatChecked: function(value) {
// return false
// },
// formatFood: function(value) {
// return value[1][0]?.food?.name ?? this.$t("Undefined")
// },
// formatUnit: function(value) {
// return value[1][0]?.unit?.name ?? this.$t("Undefined")
// },
// formatRecipe: function(value) {
// return value[1][0]?.recipe_mealplan?.recipe_name ?? this.$t("Undefined")
// },
// formatNotes: function(value) {
// return [value[1][0]?.recipe_mealplan?.mealplan_note, value?.ingredient_note].filter(String).join("\n")
// },
// formatDate: function(datetime) {
// if (!datetime) {
// return
// }
// return Intl.DateTimeFormat(window.navigator.language, { dateStyle: "short", timeStyle: "short" }).format(Date.parse(datetime))
// },
toggleChecked: function(item, refresh = false) {
// when checking a sub item don't refresh the screen until all entries complete but change class to cross out
item.checked = !item.checked
if (item.checked) {
item.completed_at = new Date().toISOString()
}
this.saveThis(item, false)
},
sectionID: function(a, b) {
return (a + b).replace(/\W/g, "")
},
openContextMenu(e, value) {
this.$refs.menu.open(e, value)
},
moveEntry: function(e, item) {
if (!e) {
makeToast(this.$t("Warning"), this.$t("NoCategory"), "warning")
}
// TODO update API to warn that category is inherited
let food = {
id: item?.[0]?.food?.id ?? item?.food?.id,
supermarket_category: this.shopping_categories.filter((x) => x?.id === e)?.[0],
}
console.log("food", food, "event", e, "item", item)
this.genericAPI(this.Models.FOOD, this.Actions.UPDATE, food)
.then((result) => {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
// TODO this needs to change order of saved item to match category or category sort needs to be local instead of based on API
this.items
.filter((x) => x.food.id == food.id)
.forEach((y) => {
y.food.supermarket_category = food.supermarket_category
this.updateEntry(y)
})
})
.catch((err) => {
console.log(err, Object.keys(err))
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
})
this.shopcat = null
},
updateEntry: function(newItem) {
let idx = this.items.indexOf((x) => x.id === newItem.id)
Vue.set(this.items, idx, newItem)
},
foodName: function(value) {
console.log(value?.food?.name ?? value?.[0]?.food?.name ?? "")
return value?.food?.name ?? value?.[0]?.food?.name ?? ""
},
ignoreThis: function(item) {
return item
},
onHand: function(item) {
return item
},
delayThis: function(item) {
return item
},
categoryName: function(value) {
return this.shopping_categories.filter((x) => x.id == value)[0]?.name ?? ""
},
stop: function(e) {
e.stopPropagation() // default @click.stop not working
e.preventDefault() // default @click.stop not working
},
},
}
</script>
<!--style src="vue-multiselect/dist/vue-multiselect.min.css"></style-->
<style>
.rotate {
-moz-transition: all 0.25s linear;
-webkit-transition: all 0.25s linear;
transition: all 0.25s linear;
}
.btn[aria-expanded="true"] > .rotate {
-moz-transform: rotate(90deg);
-webkit-transform: rotate(90deg);
transform: rotate(90deg);
}
.float-top {
padding-bottom: -3em;
margin-bottom: -3em;
}
</style>

View File

@ -1,6 +1,8 @@
<template> <template>
<!-- add alert at top if offline -->
<!-- get autosync time from preferences and put fetching checked items on timer -->
<!-- allow reordering or items -->
<div id="app" style="margin-bottom: 4vh"> <div id="app" style="margin-bottom: 4vh">
<b-alert :show="!online" dismissible class="small float-up" variant="warning">{{ $t("OfflineAlert") }}</b-alert>
<div class="row float-top"> <div class="row float-top">
<div class="offset-md-10 col-md-2 no-gutter text-right"> <div class="offset-md-10 col-md-2 no-gutter text-right">
<b-button variant="link" class="px-0"> <b-button variant="link" class="px-0">
@ -11,15 +13,15 @@
</b-button> </b-button>
</div> </div>
</div> </div>
<b-tabs content-class="mt-3"> <b-tabs content-class="mt-3">
<!-- shopping list tab -->
<b-tab :title="$t('ShoppingList')" active> <b-tab :title="$t('ShoppingList')" active>
<template #title> <b-spinner v-if="loading" type="border" small></b-spinner> {{ $t("ShoppingList") }} </template> <template #title> <b-spinner v-if="loading" type="border" small></b-spinner> {{ $t("ShoppingList") }} </template>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col col-md-12"> <div class="col col-md-12">
<!-- TODO add spinner -->
<div role="tablist" v-if="items && items.length > 0"> <div role="tablist" v-if="items && items.length > 0">
<!-- WARNING: all data in the table should be considered read-only, don't change any data through table bindings -->
<div class="row justify-content-md-center w-75" v-if="entrymode"> <div class="row justify-content-md-center w-75" v-if="entrymode">
<div class="col col-md-2 "><b-form-input min="1" type="number" :description="$t('Amount')" v-model="new_item.amount"></b-form-input></div> <div class="col col-md-2 "><b-form-input min="1" type="number" :description="$t('Amount')" v-model="new_item.amount"></b-form-input></div>
<div class="col col-md-3"> <div class="col col-md-3">
@ -72,7 +74,12 @@
<div class="collapse" :id="'section-' + sectionID(x, i)" visible role="tabpanel" :class="{ show: x == 'false' }"> <div class="collapse" :id="'section-' + sectionID(x, i)" visible role="tabpanel" :class="{ show: x == 'false' }">
<!-- passing an array of values to the table grouped by Food --> <!-- passing an array of values to the table grouped by Food -->
<div v-for="(entries, x) in Object.entries(s)" :key="x"> <div v-for="(entries, x) in Object.entries(s)" :key="x">
<ShoppingLineItem :entries="entries[1]" :groupby="group_by" @open-context-menu="openContextMenu" @update-checkbox="updateChecked" /> <ShoppingLineItem
:entries="entries[1]"
:groupby="group_by"
@open-context-menu="openContextMenu"
@update-checkbox="updateChecked"
></ShoppingLineItem>
</div> </div>
</div> </div>
</div> </div>
@ -82,11 +89,11 @@
</div> </div>
</div> </div>
</b-tab> </b-tab>
<!-- recipe tab -->
<b-tab :title="$t('Recipes')"> <b-tab :title="$t('Recipes')">
<table class="table w-75"> <table class="table w-75">
<thead> <thead>
<tr> <tr>
<th scope="col">ID</th>
<th scope="col">{{ $t("Meal_Plan") }}</th> <th scope="col">{{ $t("Meal_Plan") }}</th>
<th scope="col">{{ $t("Recipe") }}</th> <th scope="col">{{ $t("Recipe") }}</th>
<th scope="col">{{ $t("Servings") }}</th> <th scope="col">{{ $t("Servings") }}</th>
@ -94,6 +101,7 @@
</tr> </tr>
</thead> </thead>
<tr v-for="r in Recipes" :key="r.list_recipe"> <tr v-for="r in Recipes" :key="r.list_recipe">
<td>{{ r.list_recipe }}</td>
<td>{{ r.recipe_mealplan.name }}</td> <td>{{ r.recipe_mealplan.name }}</td>
<td>{{ r.recipe_mealplan.recipe_name }}</td> <td>{{ r.recipe_mealplan.recipe_name }}</td>
<td class="block-inline"> <td class="block-inline">
@ -105,124 +113,15 @@
</tr> </tr>
</table> </table>
</b-tab> </b-tab>
<!-- settings tab -->
<b-tab :title="$t('Settings')"> <b-tab :title="$t('Settings')">
<div class="row"> These are the settings <br />-sort supermarket categories<br />
<div class="col col-md-4 "> -add supermarket categories<br />
<b-card class="no-body"> - add supermarkets autosync time<br />
<div class="row"> autosync on/off<br />
<div class="col col-md-6">{{ $t("mealplan_autoadd_shopping") }}</div> always restrict supermarket to categories?<br />
<div class="col col-md-6 text-right"> when restricted or filterd - give visual indication<br />
<input type="checkbox" size="sm" v-model="settings.mealplan_autoadd_shopping" @change="saveSettings" /> how long to defer shopping - default tomorrow <br />
</div> always override inheritance
</div>
<div class="row sm mb-3">
<div class="col">
<em class="small text-muted">{{ $t("mealplan_autoadd_shopping_desc") }}</em>
</div>
</div>
<div v-if="settings.mealplan_autoadd_shopping">
<div class="row">
<div class="col col-md-6">{{ $t("mealplan_autoadd_shopping") }}</div>
<div class="col col-md-6 text-right">
<input type="checkbox" size="sm" v-model="settings.mealplan_autoexclude_onhand" @change="saveSettings" />
</div>
</div>
<div class="row sm mb-3">
<div class="col">
<em class="small text-muted">{{ $t("mealplan_autoadd_shopping_desc") }}</em>
</div>
</div>
</div>
<div v-if="settings.mealplan_autoadd_shopping">
<div class="row">
<div class="col col-md-6">{{ $t("mealplan_autoinclude_related") }}</div>
<div class="col col-md-6 text-right">
<input type="checkbox" size="sm" v-model="settings.mealplan_autoinclude_related" @change="saveSettings" />
</div>
</div>
<div class="row sm mb-3">
<div class="col">
<em class="small text-muted">
{{ $t("mealplan_autoinclude_related_desc") }}
</em>
</div>
</div>
</div>
<div class="row">
<div class="col col-md-6">{{ $t("shopping_share") }}</div>
<div class="col col-md-6 text-right">
<generic-multiselect
size="sm"
@change="
settings.shopping_share = $event.val
saveSettings()
"
:model="Models.USER"
:initial_selection="settings.shopping_share"
label="username"
:multiple="true"
:allow_create="false"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
:placeholder="$t('User')"
/>
</div>
</div>
<div class="row sm mb-3">
<div class="col">
<em class="small text-muted">{{ $t("shopping_share_desc") }}</em>
</div>
</div>
<div class="row">
<div class="col col-md-6">{{ $t("shopping_auto_sync") }}</div>
<div class="col col-md-6 text-right">
<input type="number" size="sm" v-model="settings.shopping_auto_sync" @change="saveSettings" />
</div>
</div>
<div class="row sm mb-3">
<div class="col">
<em class="small text-muted">
{{ $t("shopping_auto_sync_desc") }}
</em>
</div>
</div>
<div class="row">
<div class="col col-md-6">{{ $t("filter_to_supermarket") }}</div>
<div class="col col-md-6 text-right">
<input type="checkbox" size="sm" v-model="settings.filter_to_supermarket" @change="saveSettings" />
</div>
</div>
<div class="row sm mb-3">
<div class="col">
<em class="small text-muted">
{{ $t("filter_to_supermarket_desc") }}
</em>
</div>
</div>
<div class="row">
<div class="col col-md-6">{{ $t("default_delay") }}</div>
<div class="col col-md-6 text-right">
<input type="number" size="sm" min="1" v-model="settings.default_delay" @change="saveSettings" />
</div>
</div>
<div class="row sm mb-3">
<div class="col">
<em class="small text-muted">
{{ $t("default_delay_desc") }}
</em>
</div>
</div>
</b-card>
</div>
<div class="col col-md-8">
<b-card class=" no-body">
put the supermarket stuff here<br />
-add supermarkets<br />
-add supermarket categories<br />
-sort supermarket categories<br />
</b-card>
</div>
</div>
</b-tab> </b-tab>
</b-tabs> </b-tabs>
<b-popover target="id_filters_button" triggers="click" placement="bottomleft" :title="$t('Filters')"> <b-popover target="id_filters_button" triggers="click" placement="bottomleft" :title="$t('Filters')">
@ -243,10 +142,9 @@
<b-form-checkbox v-model="supermarket_categories_only"></b-form-checkbox> <b-form-checkbox v-model="supermarket_categories_only"></b-form-checkbox>
</b-form-group> </b-form-group>
</div> </div>
<div class="row " style="margin-top: 1vh;min-width:300px"> <div class="row" style="margin-top: 1vh;min-width:300px">
<div class="col-12 " style="text-align: right;"> <div class="col-12" style="text-align: right;">
<b-button size="sm" variant="primary" class="mx-1" @click="resetFilters">{{ $t("Reset") }} </b-button> <b-button size="sm" variant="secondary" style="margin-right:20px" @click="$root.$emit('bv::hide::popover')">{{ $t("Close") }} </b-button>
<b-button size="sm" variant="secondary" class="mr-3" @click="$root.$emit('bv::hide::popover')">{{ $t("Close") }} </b-button>
</div> </div>
</div> </div>
</b-popover> </b-popover>
@ -332,6 +230,8 @@ import { StandardToasts, makeToast } from "@/utils/utils"
Vue.use(BootstrapVue) Vue.use(BootstrapVue)
export default { export default {
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
// or i'm capturing it incorrectly
name: "ShoppingListView", name: "ShoppingListView",
mixins: [ApiMixin], mixins: [ApiMixin],
components: { ContextMenu, ContextMenuItem, ShoppingLineItem, GenericMultiselect }, components: { ContextMenu, ContextMenuItem, ShoppingLineItem, GenericMultiselect },
@ -340,7 +240,7 @@ export default {
return { return {
// this.Models and this.Actions inherited from ApiMixin // this.Models and this.Actions inherited from ApiMixin
items: [], items: [],
group_by: "category", group_by: "category", //TODO create setting to switch group_by
group_by_choices: ["created_by", "category", "recipe"], group_by_choices: ["created_by", "category", "recipe"],
supermarkets: [], supermarkets: [],
shopping_categories: [], shopping_categories: [],
@ -348,25 +248,13 @@ export default {
show_undefined_categories: true, show_undefined_categories: true,
supermarket_categories_only: false, supermarket_categories_only: false,
shopcat: null, shopcat: null,
delay: 0, delay: 0, // user default
settings: {
shopping_auto_sync: 0,
default_delay: 4,
mealplan_autoadd_shopping: false,
mealplan_autoinclude_related: false,
mealplan_autoexclude_onhand: true,
filter_to_supermarket: false,
},
autosync_id: undefined,
auto_sync_running: false,
show_delay: false, show_delay: false,
show_modal: false, show_modal: false,
fields: ["checked", "amount", "category", "unit", "food", "recipe", "details"], fields: ["checked", "amount", "category", "unit", "food", "recipe", "details"],
loading: true, loading: true,
entrymode: false, entrymode: false,
new_item: { amount: 1, unit: undefined, food: undefined }, new_item: { amount: 1, unit: undefined, food: undefined },
online: true,
} }
}, },
computed: { computed: {
@ -401,16 +289,16 @@ export default {
shopping_list = shopping_list.filter((x) => x?.food?.supermarket_category) shopping_list = shopping_list.filter((x) => x?.food?.supermarket_category)
} }
let groups = { false: {}, true: {} } // force unchecked to always be first const groups = { false: {}, true: {} } // force unchecked to always be first
if (this.selected_supermarket) { if (this.selected_supermarket) {
let super_cats = this.supermarkets let super_cats = this.supermarkets
.filter((x) => x.id === this.selected_supermarket) .filter((x) => x.id === this.selected_supermarket)
.map((x) => x.category_to_supermarket) .map((x) => x.category_to_supermarket)
.flat() .flat()
.map((x) => x.category.name) .map((x) => x.category.name)
new Set([...super_cats, ...this.shopping_categories.map((x) => x.name)]).forEach((cat) => { new Set([...super_cats, ...this.shopping_categories.map((x) => x.name)]).foreach((cat) => {
groups["false"][cat.name] = {} groups.false[cat.name] = {}
groups["true"][cat.name] = {} groups.true[cat.name] = {}
}) })
} else { } else {
this.shopping_categories.forEach((cat) => { this.shopping_categories.forEach((cat) => {
@ -443,7 +331,7 @@ export default {
return this.items.filter((x) => !x.delay_until || !Date.parse(x?.delay_until) > new Date(Date.now())).length < this.items.length return this.items.filter((x) => !x.delay_until || !Date.parse(x?.delay_until) > new Date(Date.now())).length < this.items.length
}, },
filterApplied() { filterApplied() {
return (this.itemsDelayed && !this.show_delay) || !this.show_undefined_categories || (this.supermarket_categories_only && this.selected_supermarket) return (this.itemsDelayed && !this.show_delay) || !this.show_undefined_categories || this.supermarket_categories_only
}, },
Recipes() { Recipes() {
return [...new Map(this.items.filter((x) => x.list_recipe).map((item) => [item["list_recipe"], item])).values()] return [...new Map(this.items.filter((x) => x.list_recipe).map((item) => [item["list_recipe"], item])).values()]
@ -451,49 +339,24 @@ export default {
}, },
watch: { watch: {
selected_supermarket(newVal, oldVal) { selected_supermarket(newVal, oldVal) {
this.supermarket_categories_only = this.settings.filter_to_supermarket this.getShoppingList()
},
"settings.filter_to_supermarket": function(newVal, oldVal) {
this.supermarket_categories_only = this.settings.filter_to_supermarket
},
"settings.shopping_auto_sync": function(newVal, oldVal) {
clearInterval(this.autosync_id)
this.autosync_id = undefined
if (!newVal) {
window.removeEventListener("online", this.updateOnlineStatus)
window.removeEventListener("offline", this.updateOnlineStatus)
return
} else if (oldVal === 0 && newVal > 0) {
window.addEventListener("online", this.updateOnlineStatus)
window.addEventListener("offline", this.updateOnlineStatus)
}
this.autosync_id = setInterval(() => {
if (this.online && !this.auto_sync_running) {
this.auto_sync_running = true
this.getShoppingList(true)
}
}, this.settings.shopping_auto_sync * 1000)
}, },
}, },
mounted() { mounted() {
// value is passed from lists.py
this.getShoppingList() this.getShoppingList()
this.getSupermarkets() this.getSupermarkets()
this.getShoppingCategories() this.getShoppingCategories()
this.delay = getUserPreference("default_delay") || 4
this.settings = getUserPreference()
this.delay = this.settings.default_delay || 4
this.supermarket_categories_only = this.settings.filter_to_supermarket
if (this.settings.shopping_auto_sync) {
window.addEventListener("online", this.updateOnlineStatus)
window.addEventListener("offline", this.updateOnlineStatus)
}
}, },
methods: { methods: {
// this.genericAPI inherited from ApiMixin // this.genericAPI inherited from ApiMixin
addItem() { addItem() {
console.log(this.new_item)
let api = new ApiApiFactory() let api = new ApiApiFactory()
api.createShoppingListEntry(this.new_item) api.createShoppingListEntry(this.new_item)
.then((results) => { .then((results) => {
console.log(results)
if (results?.data) { if (results?.data) {
this.items.push(results.data) this.items.push(results.data)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE) StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
@ -510,13 +373,6 @@ export default {
categoryName: function(value) { categoryName: function(value) {
return this.shopping_categories.filter((x) => x.id == value)[0]?.name ?? "" return this.shopping_categories.filter((x) => x.id == value)[0]?.name ?? ""
}, },
resetFilters: function() {
this.selected_supermarket = undefined
this.supermarket_categories_only = this.settings.filter_to_supermarket
this.show_undefined_categories = true
this.group_by = "category"
this.show_delay = false
},
delayThis: function(item) { delayThis: function(item) {
let entries = [] let entries = []
let promises = [] let promises = []
@ -580,45 +436,40 @@ export default {
this.shopping_categories = result.data this.shopping_categories = result.data
}) })
}, },
getShoppingList: function(autosync = false) { getShoppingList: function(params = {}) {
let params = {} this.loading = true
params.supermarket = this.selected_supermarket params = {
supermarket: this.selected_supermarket,
params.options = { query: { recent: 1 } }
if (autosync) {
params.options.query["autosync"] = 1
} else {
this.loading = true
} }
params.options = { query: { recent: 1 } }
this.genericAPI(this.Models.SHOPPING_LIST, this.Actions.LIST, params) this.genericAPI(this.Models.SHOPPING_LIST, this.Actions.LIST, params)
.then((results) => { .then((results) => {
if (!autosync) { console.log(results.data)
if (results.data?.length) { if (results.data?.length) {
this.items = results.data this.items = results.data
} else {
console.log("no data returned")
}
this.loading = false
} else { } else {
this.mergeShoppingList(results.data) console.log("no data returned")
} }
this.loading = false
}) })
.catch((err) => { .catch((err) => {
console.log(err) console.log(err)
if (!autosync) { StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
}
}) })
}, },
getSupermarkets: function() { getSupermarkets: function() {
let api = new ApiApiFactory() let api = new ApiApiFactory()
api.listSupermarkets().then((result) => { api.listSupermarkets().then((result) => {
this.supermarkets = result.data this.supermarkets = result.data
console.log(this.supermarkets)
}) })
}, },
getThis: function(id) { getThis: function(id) {
return this.genericAPI(this.Models.SHOPPING_CATEGORY, this.Actions.FETCH, { id: id }) return this.genericAPI(this.Models.SHOPPING_CATEGORY, this.Actions.FETCH, { id: id })
}, },
getRecipe: function(item) {
// change to get pop up card? maybe same for unit and food?
},
ignoreThis: function(item) { ignoreThis: function(item) {
let food = { let food = {
id: item?.[0]?.food.id ?? item.food.id, id: item?.[0]?.food.id ?? item.food.id,
@ -626,17 +477,6 @@ export default {
} }
this.updateFood(food, "ignore_shopping") this.updateFood(food, "ignore_shopping")
}, },
mergeShoppingList: function(data) {
this.items.map((x) =>
data.map((y) => {
if (y.id === x.id) {
x.checked = y.checked
return x
}
})
)
this.auto_sync_running = false
},
moveEntry: function(e, item) { moveEntry: function(e, item) {
if (!e) { if (!e) {
makeToast(this.$t("Warning"), this.$t("NoCategory"), "warning") makeToast(this.$t("Warning"), this.$t("NoCategory"), "warning")
@ -671,17 +511,6 @@ export default {
this.shopcat = value?.food?.supermarket_category?.id ?? value?.[0]?.food?.supermarket_category?.id ?? undefined this.shopcat = value?.food?.supermarket_category?.id ?? value?.[0]?.food?.supermarket_category?.id ?? undefined
this.$refs.menu.open(e, value) this.$refs.menu.open(e, value)
}, },
saveSettings: function() {
let api = ApiApiFactory()
api.partialUpdateUserPreference(this.settings.user, this.settings)
.then((result) => {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
})
.catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
})
},
saveThis: function(thisItem, toast = true) { saveThis: function(thisItem, toast = true) {
let api = new ApiApiFactory() let api = new ApiApiFactory()
if (!thisItem?.id) { if (!thisItem?.id) {
@ -707,6 +536,7 @@ export default {
}) })
.catch((err) => { .catch((err) => {
console.log(err, err.response) console.log(err, err.response)
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE) StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
}) })
} }
@ -716,17 +546,15 @@ export default {
}, },
updateChecked: function(update) { updateChecked: function(update) {
// when checking a sub item don't refresh the screen until all entries complete but change class to cross out // when checking a sub item don't refresh the screen until all entries complete but change class to cross out
let promises = [] let promises = []
update.entries.forEach((x) => { update.entries.forEach((x) => {
console.log({ id: x, checked: update.checked })
promises.push(this.saveThis({ id: x, checked: update.checked }, false)) promises.push(this.saveThis({ id: x, checked: update.checked }, false))
let item = this.items.filter((entry) => entry.id == x)[0] let item = this.items.filter((entry) => entry.id == x)[0]
Vue.set(item, "checked", update.checked) Vue.set(item, "checked", update.checked)
if (update.checked) { Vue.set(item, "completed_at", new Date().toISOString())
Vue.set(item, "completed_at", new Date().toISOString())
} else {
Vue.set(item, "completed_at", undefined)
}
}) })
Promise.all(promises).catch((err) => { Promise.all(promises).catch((err) => {
console.log(err, err.response) console.log(err, err.response)
@ -769,14 +597,6 @@ export default {
this.getShoppingList() this.getShoppingList()
}) })
}, },
updateOnlineStatus(e) {
const { type } = e
this.online = type === "online"
},
beforeDestroy() {
window.removeEventListener("online", this.updateOnlineStatus)
window.removeEventListener("offline", this.updateOnlineStatus)
},
}, },
} }
</script> </script>
@ -798,8 +618,4 @@ export default {
padding-bottom: -3em; padding-bottom: -3em;
margin-bottom: -3em; margin-bottom: -3em;
} }
.float-up {
padding-top: -3em;
margin-top: -3em;
}
</style> </style>

View File

@ -2,12 +2,12 @@
<!-- add alert at top if offline --> <!-- add alert at top if offline -->
<!-- get autosync time from preferences and put fetching checked items on timer --> <!-- get autosync time from preferences and put fetching checked items on timer -->
<!-- allow reordering or items --> <!-- allow reordering or items -->
<div id="app"> <div id="shopping_line_item">
<div class="col-12"> <div class="col-12">
<div class="row" :class="{ 'text-muted': formatChecked }"> <div class="row">
<div class="col col-md-1"> <div class="col col-md-1">
<div style="position: static;" class=" btn-group"> <div style="position: static;" class=" btn-group">
<div class="dropdown b-dropdown position-static"> <div class="dropdown b-dropdown position-static inline-block">
<button <button
aria-haspopup="true" aria-haspopup="true"
aria-expanded="false" aria-expanded="false"
@ -18,9 +18,8 @@
<i class="fas fa-ellipsis-v fa-lg"></i> <i class="fas fa-ellipsis-v fa-lg"></i>
</button> </button>
</div> </div>
<input type="checkbox" class="text-right mx-3 mt-2" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
</div> </div>
<b-button class="btn far text-body text-decoration-none" variant="link" @click="checkboxChanged()" :class="formatChecked ? 'fa-check-square' : 'fa-square'" />
</div> </div>
<div class="col col-md-1">{{ formatAmount }}</div> <div class="col col-md-1">{{ formatAmount }}</div>
<div class="col col-md-1">{{ formatUnit }}</div> <div class="col col-md-1">{{ formatUnit }}</div>
@ -36,8 +35,8 @@
</div> </div>
<div class="card no-body" v-if="showDetails"> <div class="card no-body" v-if="showDetails">
<div v-for="(e, z) in entries" :key="z"> <div v-for="(e, z) in entries" :key="z">
<div class="row ml-2 small" v-if="formatOneMealPlan(e)"> <div class="row ml-2 small">
<div class="col-md-4 overflow-hidden text-nowrap"> <div class="col-md-4 overflow-hidden text-nowrap">
<button <button
aria-haspopup="true" aria-haspopup="true"
aria-expanded="false" aria-expanded="false"
@ -51,12 +50,15 @@
</button> </button>
</div> </div>
<div class="col-md-4 text-muted">{{ formatOneMealPlan(e) }}</div> <div class="col-md-4 text-muted">{{ formatOneMealPlan(e) }}</div>
<div class="col-md-4 text-muted">{{ formatOneCreatedBy(e) }}</div> <div class="col-md-4 text-muted text-right">{{ formatOneCreatedBy(e) }}</div>
</div>
<div class="row ml-2 small">
<div class="col-md-4 offset-md-8 text-muted text-right">{{ formatOneCompletedAt(e) }}</div>
</div> </div>
<div class="row ml-2 light"> <div class="row ml-2 light">
<div class="col-sm-1 text-nowrap"> <div class="col-sm-1 text-nowrap">
<div style="position: static;" class=" btn-group "> <div style="position: static;" class=" btn-group ">
<div class="dropdown b-dropdown position-static"> <div class="dropdown b-dropdown position-static inline-block">
<button <button
aria-haspopup="true" aria-haspopup="true"
aria-expanded="false" aria-expanded="false"
@ -67,14 +69,8 @@
<i class="fas fa-ellipsis-v fa-lg"></i> <i class="fas fa-ellipsis-v fa-lg"></i>
</button> </button>
</div> </div>
<input type="checkbox" class="text-right mx-3 mt-2" :checked="e.checked" @change="updateChecked($event, e)" />
</div> </div>
<b-button
class="btn far text-body text-decoration-none"
variant="link"
@click="checkboxChanged(e)"
:class="formatOneChecked(e) ? 'fa-check-square' : 'fa-square'"
/>
</div> </div>
<div class="col-sm-1">{{ formatOneAmount(e) }}</div> <div class="col-sm-1">{{ formatOneAmount(e) }}</div>
<div class="col-sm-2">{{ formatOneUnit(e) }}</div> <div class="col-sm-2">{{ formatOneUnit(e) }}</div>
@ -84,15 +80,26 @@
<div class="small" v-for="(n, i) in formatOneNote(e)" :key="i">{{ n }}</div> <div class="small" v-for="(n, i) in formatOneNote(e)" :key="i">{{ n }}</div>
</div> </div>
</div> </div>
<hr class="w-75" /> <hr class="w-75" />
</div> </div>
</div> </div>
<hr class="m-1" /> <hr class="m-1" />
</div> </div>
<ContextMenu ref="recipe_card" triggers="click, hover" :title="$t('Filters')" style="max-width:300"> <ContextMenu ref="recipe_card" triggers="click, hover" :title="$t('Filters')" style="max-width:300">
<template #menu="{ contextData }"> <template #menu="{ contextData }" v-if="recipe">
<ContextMenuItem><RecipeCard :recipe="contextData" :detail="false" v-if="recipe"></RecipeCard></ContextMenuItem <ContextMenuItem><RecipeCard :recipe="contextData" :detail="false"></RecipeCard></ContextMenuItem>
></template> <ContextMenuItem @click="$refs.menu.close()">
<b-form-group label-cols="9" content-cols="3" class="text-nowrap m-0 mr-2">
<template #label>
<a class="dropdown-item p-2" href="#"><i class="fas fa-pizza-slice"></i> {{ $t("Servings") }}</a>
</template>
<div @click.prevent.stop>
<b-form-input class="mt-2" min="0" type="number" v-model="servings"></b-form-input>
</div>
</b-form-group>
</ContextMenuItem>
</template>
</ContextMenu> </ContextMenu>
</div> </div>
</template> </template>
@ -124,6 +131,7 @@ export default {
return { return {
showDetails: false, showDetails: false,
recipe: undefined, recipe: undefined,
servings: 1,
} }
}, },
computed: { computed: {
@ -134,7 +142,7 @@ export default {
return this.formatOneCategory(this.entries[0]) || this.$t("Undefined") return this.formatOneCategory(this.entries[0]) || this.$t("Undefined")
}, },
formatChecked: function() { formatChecked: function() {
return false return this.entries.map((x) => x.checked).every((x) => x === true)
}, },
formatHint: function() { formatHint: function() {
if (this.groupby == "recipe") { if (this.groupby == "recipe") {
@ -165,7 +173,9 @@ export default {
}, },
}, },
watch: {}, watch: {},
mounted() {}, mounted() {
this.servings = this.entries?.[0]?.recipe_mealplan?.servings ?? 0
},
methods: { methods: {
// this.genericAPI inherited from ApiMixin // this.genericAPI inherited from ApiMixin
@ -175,16 +185,6 @@ export default {
} }
return Intl.DateTimeFormat(window.navigator.language, { dateStyle: "short", timeStyle: "short" }).format(Date.parse(datetime)) return Intl.DateTimeFormat(window.navigator.language, { dateStyle: "short", timeStyle: "short" }).format(Date.parse(datetime))
}, },
checkboxChanged: function() {
console.log("click!")
// item.checked = !item.checked
// if (item.checked) {
// item.completed_at = new Date().toISOString()
// }
// this.saveThis(item, false)
// this.$refs.table.refresh()
},
formatOneAmount: function(item) { formatOneAmount: function(item) {
return item?.amount ?? 1 return item?.amount ?? 1
}, },
@ -194,6 +194,12 @@ export default {
formatOneCategory: function(item) { formatOneCategory: function(item) {
return item?.food?.supermarket_category?.name return item?.food?.supermarket_category?.name
}, },
formatOneCompletedAt: function(item) {
if (!item.completed_at) {
return ""
}
return [this.$t("Completed"), "@", this.formatDate(item.completed_at)].join(" ")
},
formatOneFood: function(item) { formatOneFood: function(item) {
return item.food.name return item.food.name
}, },
@ -223,6 +229,14 @@ export default {
this.$refs.recipe_card.open(e, recipe) this.$refs.recipe_card.open(e, recipe)
}) })
}, },
updateChecked: function(e, item) {
if (!item) {
let update = { entries: this.entries.map((x) => x.id), checked: !this.formatChecked }
this.$emit("update-checkbox", update)
} else {
this.$emit("update-checkbox", { id: item.id, checked: !item.checked })
}
},
}, },
} }
</script> </script>

View File

@ -91,9 +91,9 @@ module.exports = {
}, },
// TODO make this conditional on .env DEBUG = FALSE // TODO make this conditional on .env DEBUG = FALSE
config.optimization.minimize(false) config.optimization.minimize(false)
); )
config.plugin('BundleTracker').use(BundleTracker, [{relativePath: true, path: '../vue/'}]); config.plugin("BundleTracker").use(BundleTracker, [{ relativePath: true, path: "../vue/" }])
config.resolve.alias.set("__STATIC__", "static") config.resolve.alias.set("__STATIC__", "static")