shopping line item

This commit is contained in:
smilerz 2021-10-21 17:44:45 -05:00
parent 79b6d4817e
commit 5e4e203dfb
5 changed files with 559 additions and 411 deletions

View File

@ -1,11 +1,42 @@
from django.db.models import Q from datetime import timedelta
from django.contrib.postgres.aggregates import ArrayAgg
from django.db.models import F, OuterRef, Q, Subquery, Value
from django.db.models.functions import Coalesce
from django.utils import timezone from django.utils import timezone
from cookbook.models import UserPreference from cookbook.models import UserPreference
def shopping_helper(qs, request): def shopping_helper(qs, request):
today_start = timezone.now().replace(hour=0, minute=0, second=0) supermarket = request.query_params.get('supermarket', None)
qs = qs.filter(Q(shoppinglist__created_by=request.user) | Q(shoppinglist__shared=request.user)).filter(shoppinglist__space=request.space) checked = request.query_params.get('checked', 'recent')
qs = qs.filter(Q(checked=False) | Q(completed_at__gte=today_start))
return qs supermarket_order = ['food__supermarket_category__name', 'food__name']
# TODO created either scheduled task or startup task to delete very old shopping list entries
# TODO create user preference to define 'very old'
# qs = qs.annotate(supermarket_category=Coalesce(F('food__supermarket_category__name'), Value(_('Undefined'))))
# TODO add supermarket to API - order by category order
if supermarket:
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)))
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']:
qs = qs.filter(checked=False)
elif checked in ['true', 1, '1']:
qs = qs.filter(checked=True)
elif checked in ['recent']:
today_start = timezone.now().replace(hour=0, minute=0, second=0)
# TODO make recent a user setting
week_ago = today_start - timedelta(days=7)
qs = qs.filter(Q(checked=False) | Q(completed_at__gte=week_ago))
supermarket_order = ['checked'] + supermarket_order
return qs.order_by(*supermarket_order).select_related('unit', 'food', 'list_recipe__mealplan', 'list_recipe__recipe')

View File

@ -1,10 +1,12 @@
# Generated by Django 3.2.7 on 2021-10-01 22:34 # Generated by Django 3.2.7 on 2021-10-01 22:34
import datetime import datetime
from datetime import timedelta
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
from django.utils import timezone
from django.utils.timezone import utc from django.utils.timezone import utc
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
@ -26,6 +28,14 @@ def create_inheritfields(apps, schema_editor):
FoodInheritField.objects.create(name='Substitute Siblings', field='substitute_siblings') FoodInheritField.objects.create(name='Substitute Siblings', field='substitute_siblings')
def set_completed_at(apps, schema_editor):
today_start = timezone.now().replace(hour=0, minute=0, second=0)
# arbitrary - keeping all of the closed shopping list items out of the 'recent' view
month_ago = today_start - timedelta(days=30)
with scopes_disabled():
ShoppingListEntry.objects.filter(checked=True).update(completed_at=month_ago)
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@ -36,4 +46,5 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.RunPython(delete_orphaned_sle), migrations.RunPython(delete_orphaned_sle),
migrations.RunPython(create_inheritfields), migrations.RunPython(create_inheritfields),
migrations.RunPython(set_completed_at),
] ]

View File

@ -631,7 +631,7 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
class ShoppingListRecipeSerializer(serializers.ModelSerializer): class ShoppingListRecipeSerializer(serializers.ModelSerializer):
name = serializers.SerializerMethodField('get_name') # should this be done at the front end? name = serializers.SerializerMethodField('get_name') # should this be done at the front end?
recipe_name = serializers.ReadOnlyField(source='recipe.name') recipe_name = serializers.ReadOnlyField(source='recipe.name')
mealplan_note = serializers.SerializerMethodField('get_note_markdown') mealplan_note = serializers.ReadOnlyField(source='mealplan.note')
servings = CustomDecimalField() servings = CustomDecimalField()
def get_note_markdown(self, obj): def get_note_markdown(self, obj):
@ -639,7 +639,7 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ShoppingListRecipe model = ShoppingListRecipe
fields = ('id', 'recipe', 'mealplan', 'recipe_name', 'servings', 'mealplan_note') fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note')
read_only_fields = ('id',) read_only_fields = ('id',)
@ -679,6 +679,12 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
): ):
# if checked flips from false to true set completed datetime # if checked flips from false to true set completed datetime
data['completed_at'] = timezone.now() data['completed_at'] = timezone.now()
elif not data.get('checked', False):
# if not checked set completed to None
data['completed_at'] = None
else:
# otherwise don't write anything
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
@ -707,7 +713,7 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
'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'
) )
read_only_fields = ('id', 'created_by', 'created_at',) read_only_fields = ('id', 'list_recipe', 'created_by', 'created_at',)
# TODO deprecate # TODO deprecate

View File

@ -1,255 +1,469 @@
<template> <template>
<div id="app" style="margin-bottom: 4vh" v-if="this_model"> <!-- add alert at top if offline -->
<div class="row"> <!-- get autosync time from preferences and put fetching checked items on timer -->
<div class="col-md-2 d-none d-md-block"> <!-- allow reordering or items -->
</div> <div id="app" style="margin-bottom: 4vh">
<div class="col-xl-8 col-12"> <div class="row float-top">
<div class="container-fluid d-flex flex-column flex-grow-1"> <div class="offset-md-10 col-md-2 no-gutter text-right">
<b-button variant="link" class="px-0">
<div class="row"> <i class="btn fas fa-plus-circle text-muted fa-lg" @click="startAction({ action: 'new' })" />
<div class="col-md-6" style="margin-top: 1vh"> </b-button>
<h3> <b-button variant="link" id="id_filters_button" class="px-0">
<!-- <model-menu/> Replace with a List Menu or a Checklist Menu? --> <i class="btn fas fa-filter text-decoration-none text-muted fa-lg" />
<span>{{ this.this_model.name }}</span> </b-button>
<span><b-button variant="link" @click="startAction({'action':'new'})"><i
class="fas fa-plus-circle fa-2x"></i></b-button></span>
</h3>
</div> </div>
</div>
<div class="row">
<div class="col col-md-12">
<b-table small :items="items" :fields="fields" :busy="loading">
<template #cell(checked)="data">
<b-form-checkbox
v-model="data.item.checked"
@change="checkboxChanged(data.item)"
/>
</template>
<template #cell(all_notes)="data">
{{ formatNotes(data.item) }}
</template>
</b-table>
<!-- <div v-for="i in items" v-bind:key="i.id">
{{i.supermarket_category}} --- {{i.food && i.food.name || 'null'}} --- {{i.id}} --- {{i.checked}} --- {{i.amount}} --- {{i.unit && i.unit.name || 'null'}} --- {{i.list_recipe && i.list_recipe.recipe_name || 'null'}} --- {{allNotes(i)}}
</div> -->
</div>
</div>
</div> </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.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 style="position: static;" class=" btn-group">
<div class="dropdown b-dropdown position-static">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
@click.stop="openContextMenu($event, entries[1])"
>
<i class="fas fa-ellipsis-v fa-lg"></i>
</button>
</div>
</div>
<b-button
class="btn far text-body text-decoration-none"
variant="link"
@click="checkboxChanged(entries.item)"
:class="formatChecked ? 'fa-square' : 'fa-check-square'"
/>
{{ entries[0] }} {{ entries[1] }}
</div> -->
<!-- <b-table ref="table" small :items="Object.entries(s)" :fields="Fields" responsive="sm" class="w-100">
<template #cell(checked)="row">
<div style="position: static;" class=" btn-group">
<div class="dropdown b-dropdown position-static">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
@click.stop="openContextMenu($event, row)"
>
<i class="fas fa-ellipsis-v fa-lg"></i>
</button>
</div>
</div>
<b-button
class="btn far text-body text-decoration-none"
variant="link"
@click="checkboxChanged(data.item)"
:class="row.item.checked ? 'fa-check-square' : 'fa-square'"
/>
</template>
<template #cell(amount)="row">
{{ formatAmount(row.item) }}
</template>
<template #cell(food)="row">
{{ formatFood(row.item) }}
</template>
<template #cell(recipe)="row">
{{ formatRecipe(row.item) }}
</template>
<template #cell(unit)="row">
{{ formatUnit(row.item) }}
</template>
<template #cell(category)="row">
{{ formatCategory(row.item.food.supermarket_category) }}
</template>
<template #cell(details)="row">
<b-button size="sm" @click="row.toggleDetails" class="mr-2" variant="link">
<div class="text-nowrap">{{ row.detailsShowing ? "Hide" : "Show" }} Details</div>
</b-button>
</template>
<template #row-details="row">
notes {{ formatNotes(row.item) }} <br />
by {{ row.item.created_by.username }}<br />
at {{ formatDate(row.item.created_at) }}<br />
<div v-if="row.item.checked">completed {{ formatDate(row.item.completed_at) }}</div>
</template>
</b-table> -->
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</b-tab>
<b-tab :title="$t('Settings')"> These are the settings</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>
<b-form-group label-cols="6" content-cols="6" class="text-nowrap m-0 mr-2">
<template #label>
<a class="dropdown-item p-2" href="#"><i class="fas fa-cubes"></i> {{ $t("MoveCategory") }}</a>
</template>
<b-form-select
class="mt-2"
:options="shopping_categories"
text-field="name"
value-field="id"
v-model="shopcat"
@change="
$refs.menu.close()
moveEntry($event, contextData)
"
></b-form-select>
</b-form-group>
</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> </div>
</div>
</template> </template>
<script> <script>
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
import Vue from 'vue' import ContextMenu from "@/components/ContextMenu/ContextMenu"
import {BootstrapVue} from 'bootstrap-vue' import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
import ShoppingLineItem from "@/components/ShoppingLineItem"
import 'bootstrap-vue/dist/bootstrap-vue.css' import { ApiMixin } from "@/utils/utils"
import { ApiApiFactory } from "@/utils/openapi/api"
import {ApiMixin} from "@/utils/utils"; import { StandardToasts } from "@/utils/utils"
import {StandardToasts} 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 // TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
// or i'm capturing it incorrectly // or i'm capturing it incorrectly
name: 'ChecklistView', name: "ChecklistView",
mixins: [ApiMixin], mixins: [ApiMixin],
components: { }, components: { ContextMenu, ContextMenuItem, ShoppingLineItem },
data() {
return {
// this.Models and this.Actions inherited from ApiMixin
items: [],
this_model: undefined,
model_menu: undefined,
this_action: undefined,
this_item: {},
show_modal: false,
fields: [
{
'key': 'checked',
'label': '',
class: 'text-center'
},
{
'key': 'food.supermarket_category.name',
'label': this.$t('Category'),
'formatter': 'formatCategory',
},
{
'key': 'amount',
class: 'text-center'
},
{
'key': 'unit.name',
'label': this.$t('Unit')
},
{
'key': 'food.name',
'label': this.$t('Food')
},
{
'key': 'recipe_mealplan.name',
'label': this.$t('Recipe'),
},
{
'key': 'all_notes',
'label': this.$t('Notes'),
'formatter': 'formatNotes',
},
{
'key': 'created_by.username',
'label': this.$t('Added_by'),
},
{
'key': 'created_at',
'label': this.$t('Added_on'),
'formatter': 'formatDate',
},
],
loading: true
}
},
mounted() {
// value is passed from lists.py
let model_config = JSON.parse(document.getElementById('model_config').textContent)
this.this_model = this.Models[model_config?.model]
this.getItems()
},
methods: {
// this.genericAPI inherited from ApiMixin
startAction: function (e, param) {
let source = e?.source ?? {}
this.this_item = source
// remove recipe from shopping list
// mark on-hand
// mark puchased
// edit shopping category on food
// delete food from shopping list
// add food to shopping list
// add other to shopping list
// edit unit conversion
// edit purchaseable unit
switch (e.action) {
case 'delete':
this.this_action = this.Actions.DELETE
this.show_modal = true
break;
case 'new':
this.this_action = this.Actions.CREATE
this.show_modal = true
break;
case 'edit':
this.this_item = e.source
this.this_action = this.Actions.UPDATE
this.show_modal = true
break;
}
},
finishAction: function (e) {
let update = undefined
switch (e?.action) {
case 'save':
this.saveThis(e.form_data)
break;
}
if (e !== 'cancel') {
switch (this.this_action) {
case this.Actions.DELETE:
this.deleteThis(this.this_item.id)
break;
case this.Actions.CREATE:
this.saveThis(e.form_data)
break;
case this.Actions.UPDATE:
update = e.form_data
update.id = this.this_item.id
this.saveThis(update)
break;
case this.Actions.MERGE:
this.mergeThis(this.this_item, e.form_data.target, false)
break;
case this.Actions.MOVE:
this.moveThis(this.this_item.id, e.form_data.target.id)
break;
}
}
this.clearState()
},
getItems: function (params={}) {
params.options = {'query':{'extended': 1}} // returns extended values in API response
this.genericAPI(this.this_model, this.Actions.LIST, params).then((results) => {
console.log('generic', results, results.data?.length)
if (results.data?.length) {
this.items = results.data
} else {
console.log('no data returned')
}
}).catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
})
},
getThis: function (id) {
return this.genericAPI(this.this_model, 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.this_model, 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.this_model, 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.this_model, this.Actions.DELETE, {'id': id}).then((result) => {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
}).catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
})
},
clearState: function () {
this.show_modal = false
this.this_action = undefined
this.this_item = undefined
},
formatCategory: function(value) {
return value || this.$t('Undefined')
},
formatNotes: function (item) {
return [item?.recipe_mealplan?.mealplan_note, item?.ingredient_note,].filter(String).join("\n")
},
formatDate: function (datetime) {
return Intl.DateTimeFormat(window.navigator.language, { dateStyle: 'short', timeStyle: 'short' }).format(Date.parse(datetime))
},
checkboxChanged: function(item) {
this.saveThis(item, false)
}
}
}
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,
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()
},
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) {
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) {
// TODO update API to warn that category is inherited
let 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)
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)
},
},
}
</script> </script>
<!--style src="vue-multiselect/dist/vue-multiselect.min.css"></style--> <!--style src="vue-multiselect/dist/vue-multiselect.min.css"></style-->
<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> </style>

View File

@ -2,108 +2,68 @@
<!-- 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="shopping_line_item"> <div id="app">
<div class="col-12"> <div class="col-12">
<div class="row"> <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 inline-block"> <div class="dropdown b-dropdown position-static">
<button <button
aria-haspopup="true" aria-haspopup="true"
aria-expanded="false" aria-expanded="false"
type="button" type="button"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret" class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
@click.stop="$emit('open-context-menu', $event, entries)" @click.stop="$emit('open-context-menu', $event, entries[0])"
> >
<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>
</div>
<div class="col-sm-3">
<div v-if="Object.entries(formatAmount).length == 1">{{ Object.entries(formatAmount)[0][1] }} &ensp; {{ Object.entries(formatAmount)[0][0] }}</div>
<div class="small" v-else v-for="(x, i) in Object.entries(formatAmount)" :key="i">{{ x[1] }} &ensp; {{ x[0] }}</div>
</div>
<div class="col col-md-6"> <b-button class="btn far text-body text-decoration-none" variant="link" @click="checkboxChanged()" :class="formatChecked ? 'fa-check-square' : 'fa-square'" />
{{ formatFood }} <span class="small text-muted">{{ formatHint }}</span>
</div> </div>
<div class="col col-md-1">{{ formatAmount }}</div>
<div class="col col-md-1">{{ formatUnit }}</div>
<div class="col col-md-4">
{{ formatFood }} <span class="text-muted">({{ formatHint }})</span>
</div>
<div class="col col-md-1">{{ formatNotes }}</div>
<div class="col col-md-1"> <div class="col col-md-1">
<b-button size="sm" @click="showDetails = !showDetails" class="mr-2" variant="link"> <b-button size="sm" @click="showDetails = !showDetails" class="mr-2" variant="link">
<div class="text-nowrap">{{ showDetails ? "Hide" : "Show" }} Details</div> <div class="text-nowrap">{{ showDetails ? "Hide" : "Show" }} Details</div>
</b-button> </b-button>
</div> </div>
</div> </div>
<div class="card no-body" v-if="showDetails"> <div class="row" v-if="showDetails">
<div v-for="(e, z) in entries" :key="z"> <div class="offset-md-1">
<div class="row ml-2 small"> <div v-for="e in entries" :key="e.id">
<div class="col-md-4 overflow-hidden text-nowrap"> <div style="position: static;" class=" btn-group">
<button <div class="dropdown b-dropdown position-static">
aria-haspopup="true" <button
aria-expanded="false" aria-haspopup="true"
type="button" aria-expanded="false"
class="btn btn-link btn-sm m-0 p-0" type="button"
style="text-overflow: ellipsis;" class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
@click.stop="openRecipeCard($event, e)" @click.stop="$emit('open-context-menu', $event, e)"
@mouseover="openRecipeCard($event, e)" >
> <i class="fas fa-ellipsis-v fa-lg"></i>
{{ formatOneRecipe(e) }} </button>
</button>
</div>
<div class="col-md-4 text-muted">{{ formatOneMealPlan(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 class="row ml-2 light">
<div class="col-sm-1 text-nowrap">
<div style="position: static;" class=" btn-group ">
<div class="dropdown b-dropdown position-static inline-block">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
@click.stop="$emit('open-context-menu', $event, e)"
>
<i class="fas fa-ellipsis-v fa-lg"></i>
</button>
</div>
<input type="checkbox" class="text-right mx-3 mt-2" :checked="e.checked" @change="updateChecked($event, e)" />
</div> </div>
</div> </div>
<div class="col-sm-1">{{ formatOneAmount(e) }}</div>
<div class="col-sm-2">{{ formatOneUnit(e) }}</div>
<div class="col-sm-3">{{ formatOneFood(e) }}</div> <b-button
class="btn far text-body text-decoration-none"
<div class="col-sm-4"> variant="link"
<div class="small" v-for="(n, i) in formatOneNote(e)" :key="i">{{ n }}</div> @click="checkboxChanged()"
</div> :class="formatChecked ? 'fa-check-square' : 'fa-square'"
/>
{{ e.amount }} - {{ e.unit }}- {{ e.recipe }}- {{ e.mealplan }}- {{ e.note }}- {{ e.unit }}
</div> </div>
<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">
<template #menu="{ contextData }" v-if="recipe">
<ContextMenuItem><RecipeCard :recipe="contextData" :detail="false"></RecipeCard></ContextMenuItem>
<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>
</div> </div>
</template> </template>
@ -111,10 +71,6 @@
import Vue from "vue" import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue" import { BootstrapVue } from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css" import "bootstrap-vue/dist/bootstrap-vue.css"
import ContextMenu from "@/components/ContextMenu/ContextMenu"
import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
import { ApiMixin } from "@/utils/utils"
import RecipeCard from "./RecipeCard.vue"
Vue.use(BootstrapVue) Vue.use(BootstrapVue)
@ -122,8 +78,8 @@ export default {
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available // TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
// or i'm capturing it incorrectly // or i'm capturing it incorrectly
name: "ShoppingLineItem", name: "ShoppingLineItem",
mixins: [ApiMixin], mixins: [],
components: { RecipeCard, ContextMenu, ContextMenuItem }, components: {},
props: { props: {
entries: { entries: {
type: Array, type: Array,
@ -133,30 +89,17 @@ export default {
data() { data() {
return { return {
showDetails: false, showDetails: false,
recipe: undefined,
servings: 1,
} }
}, },
computed: { computed: {
formatAmount: function() { formatAmount: function() {
let amount = {} return this.entries[0].amount
this.entries.forEach((entry) => {
let unit = entry?.unit?.name ?? "----"
if (entry.amount) {
if (amount[unit]) {
amount[unit] += entry.amount
} else {
amount[unit] = entry.amount
}
}
})
return amount
}, },
formatCategory: function() { formatCategory: function() {
return this.formatOneCategory(this.entries[0]) || this.$t("Undefined") return this.entries[0]?.food?.supermarket_category?.name ?? this.$t("Undefined")
}, },
formatChecked: function() { formatChecked: function() {
return this.entries.map((x) => x.checked).every((x) => x === true) return false
}, },
formatHint: function() { formatHint: function() {
if (this.groupby == "recipe") { if (this.groupby == "recipe") {
@ -166,30 +109,24 @@ export default {
} }
}, },
formatFood: function() { formatFood: function() {
return this.formatOneFood(this.entries[0]) return this.entries[0]?.food?.name ?? this.$t("Undefined")
}, },
formatUnit: function() { formatUnit: function() {
return this.formatOneUnit(this.entries[0]) return this.entries[0]?.unit?.name ?? this.$t("Undefined")
}, },
formatRecipe: function() { formatRecipe: function() {
if (this.entries?.length == 1) { if (this.entries.length == 1) {
return this.formatOneMealPlan(this.entries[0]) || "" return this.entries[0]?.recipe_mealplan?.name ?? this.$t("Undefined")
} else { } else {
let mealplan_name = this.entries.filter((x) => x?.recipe_mealplan?.name) return [this.entries[0]?.recipe_mealplan?.name ?? this.$t("Undefined"), this.$t("CountMore", { count: this.entries.length - 1 })].join(" ")
return [this.formatOneMealPlan(mealplan_name?.[0]), this.$t("CountMore", { count: this.entries?.length - 1 })].join(" ")
} }
}, },
formatNotes: function() { formatNotes: function() {
if (this.entries?.length == 1) { return [this.entries[0]?.recipe_mealplan?.mealplan_note, this.entries?.ingredient_note].filter(String).join("\n")
return this.formatOneNote(this.entries[0]) || ""
}
return ""
}, },
}, },
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
@ -199,57 +136,15 @@ 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))
}, },
formatOneAmount: function(item) { checkboxChanged: function() {
return item?.amount ?? 1 console.log("click!")
}, // item.checked = !item.checked
formatOneUnit: function(item) { // if (item.checked) {
return item?.unit?.name ?? "" // item.completed_at = new Date().toISOString()
}, // }
formatOneCategory: function(item) {
return item?.food?.supermarket_category?.name // this.saveThis(item, false)
}, // this.$refs.table.refresh()
formatOneCompletedAt: function(item) {
if (!item.completed_at) {
return ""
}
return [this.$t("Completed"), "@", this.formatDate(item.completed_at)].join(" ")
},
formatOneFood: function(item) {
return item.food.name
},
formatOneChecked: function(item) {
return item.checked
},
formatOneMealPlan: function(item) {
return item?.recipe_mealplan?.name
},
formatOneRecipe: function(item) {
return item?.recipe_mealplan?.recipe_name
},
formatOneNote: function(item) {
if (!item) {
item = this.entries[0]
}
return [item?.recipe_mealplan?.mealplan_note, item?.ingredient_note].filter(String)
},
formatOneCreatedBy: function(item) {
return [item?.created_by.username, "@", this.formatDate(item.created_at)].join(" ")
},
openRecipeCard: function(e, item) {
this.genericAPI(this.Models.RECIPE, this.Actions.FETCH, { id: item.recipe_mealplan.recipe }).then((result) => {
let recipe = result.data
recipe.steps = undefined
this.recipe = true
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 })
}
}, },
}, },
} }
@ -257,13 +152,4 @@ export default {
<!--style src="vue-multiselect/dist/vue-multiselect.min.css"></style--> <!--style src="vue-multiselect/dist/vue-multiselect.min.css"></style-->
<style> <style></style>
/* table { border-collapse:collapse } /* Ensure no space between cells */
/* tr.strikeout td { position:relative } /* Setup a new coordinate system */
/* tr.strikeout td:before { /* Create a new element that */
/* content: " "; /* …has no text content */
/* position: absolute; /* …is absolutely positioned */
/* left: 0; top: 50%; width: 100%; /* …with the top across the middle */
/* border-bottom: 1px solid #000; /* …and with a border on the top */
/* } */
</style>