mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2026-01-01 04:10:06 -05:00
Fix after rebase
This commit is contained in:
@@ -1,195 +0,0 @@
|
||||
<template>
|
||||
<div id="app" style="margin-bottom: 4vh" v-if="this_model">
|
||||
<generic-modal-form v-if="this_model"
|
||||
:model="this_model"
|
||||
:action="this_action"
|
||||
:item1="this_item"
|
||||
:item2="this_target"
|
||||
:show="show_modal"
|
||||
@finish-action="finishAction"/>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-2 d-none d-md-block">
|
||||
</div>
|
||||
<div class="col-xl-8 col-12">
|
||||
<div class="container-fluid d-flex flex-column flex-grow-1">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6" style="margin-top: 1vh">
|
||||
<h3>
|
||||
<!-- <model-menu/> Replace with a List Menu or a Checklist Menu? -->
|
||||
<span>{{ this.this_model.name }}</span>
|
||||
<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 class="row">
|
||||
<div class="col col-md-12">
|
||||
this is where shopping list items go
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import Vue from 'vue'
|
||||
import {BootstrapVue} from 'bootstrap-vue'
|
||||
|
||||
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
||||
|
||||
import {ApiMixin} from "@/utils/utils";
|
||||
import {StandardToasts, ToastMixin} from "@/utils/utils";
|
||||
|
||||
import GenericModalForm from "@/components/Modals/GenericModalForm";
|
||||
|
||||
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: 'ModelListView',
|
||||
mixins: [ApiMixin, ToastMixin],
|
||||
components: {GenericModalForm},
|
||||
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,
|
||||
}
|
||||
},
|
||||
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]
|
||||
},
|
||||
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) {
|
||||
this.genericAPI(this.this_model, this.Actions.LIST, params).then((results) => {
|
||||
if (results?.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) {
|
||||
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
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
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
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||
}).catch((err) => {
|
||||
console.log(err, err.response)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
@@ -1,18 +0,0 @@
|
||||
import Vue from 'vue'
|
||||
import App from './ChecklistView'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
// TODO move this and other default stuff to centralized JS file (verify nothing breaks)
|
||||
let publicPath = localStorage.STATIC_URL + 'vue/'
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
publicPath = 'http://localhost:8080/'
|
||||
}
|
||||
export default __webpack_public_path__ = publicPath // eslint-disable-line
|
||||
|
||||
|
||||
new Vue({
|
||||
i18n,
|
||||
render: h => h(App),
|
||||
}).$mount('#app')
|
||||
@@ -61,10 +61,10 @@ import Vue from 'vue'
|
||||
import {BootstrapVue} from 'bootstrap-vue'
|
||||
|
||||
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||
import CookbookSlider from "@/components/CookbookSlider";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
import {StandardToasts} from "@/utils/utils";
|
||||
import {ApiApiFactory} from "@/utils/openapi/api.ts";
|
||||
import CookbookSlider from "../../components/CookbookSlider";
|
||||
import LoadingSpinner from "../../components/LoadingSpinner";
|
||||
import {StandardToasts} from "../../utils/utils";
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,14 +25,7 @@
|
||||
</h3>
|
||||
</div>
|
||||
<div class="col-md-3" style="position: relative; margin-top: 1vh">
|
||||
<b-form-checkbox
|
||||
v-model="show_split"
|
||||
name="check-button"
|
||||
v-if="paginated"
|
||||
class="shadow-none"
|
||||
style="position: relative; top: 50%; transform: translateY(-50%)"
|
||||
switch
|
||||
>
|
||||
<b-form-checkbox v-model="show_split" name="check-button" v-if="paginated" class="shadow-none" style="position: relative; top: 50%; transform: translateY(-50%)" switch>
|
||||
{{ $t("show_split_screen") }}
|
||||
</b-form-checkbox>
|
||||
</div>
|
||||
@@ -42,46 +35,19 @@
|
||||
<div class="col" :class="{ 'col-md-6': show_split }">
|
||||
<!-- model isn't paginated and loads in one API call -->
|
||||
<div v-if="!paginated">
|
||||
<generic-horizontal-card
|
||||
v-for="i in items_left"
|
||||
v-bind:key="i.id"
|
||||
:item="i"
|
||||
:model="this_model"
|
||||
@item-action="startAction($event, 'left')"
|
||||
@finish-action="finishAction"
|
||||
/>
|
||||
<generic-horizontal-card v-for="i in items_left" v-bind:key="i.id" :item="i" :model="this_model" @item-action="startAction($event, 'left')" @finish-action="finishAction" />
|
||||
</div>
|
||||
<!-- model is paginated and needs managed -->
|
||||
<generic-infinite-cards v-if="paginated" :card_counts="left_counts" :scroll="show_split" @search="getItems($event, 'left')" @reset="resetList('left')">
|
||||
<template v-slot:cards>
|
||||
<generic-horizontal-card
|
||||
v-for="i in items_left"
|
||||
v-bind:key="i.id"
|
||||
:item="i"
|
||||
:model="this_model"
|
||||
@item-action="startAction($event, 'left')"
|
||||
@finish-action="finishAction"
|
||||
/>
|
||||
<generic-horizontal-card v-for="i in items_left" v-bind:key="i.id" :item="i" :model="this_model" @item-action="startAction($event, 'left')" @finish-action="finishAction" />
|
||||
</template>
|
||||
</generic-infinite-cards>
|
||||
</div>
|
||||
<div class="col col-md-6" v-if="show_split">
|
||||
<generic-infinite-cards
|
||||
v-if="this_model"
|
||||
:card_counts="right_counts"
|
||||
:scroll="show_split"
|
||||
@search="getItems($event, 'right')"
|
||||
@reset="resetList('right')"
|
||||
>
|
||||
<generic-infinite-cards v-if="this_model" :card_counts="right_counts" :scroll="show_split" @search="getItems($event, 'right')" @reset="resetList('right')">
|
||||
<template v-slot:cards>
|
||||
<generic-horizontal-card
|
||||
v-for="i in items_right"
|
||||
v-bind:key="i.id"
|
||||
:item="i"
|
||||
:model="this_model"
|
||||
@item-action="startAction($event, 'right')"
|
||||
@finish-action="finishAction"
|
||||
/>
|
||||
<generic-horizontal-card v-for="i in items_right" v-bind:key="i.id" :item="i" :model="this_model" @item-action="startAction($event, 'right')" @finish-action="finishAction" />
|
||||
</template>
|
||||
</generic-infinite-cards>
|
||||
</div>
|
||||
@@ -98,13 +64,12 @@ import { BootstrapVue } from "bootstrap-vue"
|
||||
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
|
||||
import { CardMixin, ApiMixin, getConfig } from "@/utils/utils"
|
||||
import { StandardToasts, ToastMixin } from "@/utils/utils"
|
||||
import { CardMixin, ApiMixin, getConfig, StandardToasts, getUserPreference, makeToast } from "@/utils/utils"
|
||||
|
||||
import GenericInfiniteCards from "@/components/GenericInfiniteCards"
|
||||
import GenericHorizontalCard from "@/components/GenericHorizontalCard"
|
||||
import GenericModalForm from "@/components/Modals/GenericModalForm"
|
||||
import ModelMenu from "@/components/ModelMenu"
|
||||
import ModelMenu from "@/components/ContextMenu/ModelMenu"
|
||||
import { ApiApiFactory } from "@/utils/openapi/api"
|
||||
//import StorageQuota from "@/components/StorageQuota";
|
||||
|
||||
@@ -114,13 +79,8 @@ 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: "ModelListView",
|
||||
mixins: [CardMixin, ApiMixin, ToastMixin],
|
||||
components: {
|
||||
GenericHorizontalCard,
|
||||
GenericModalForm,
|
||||
GenericInfiniteCards,
|
||||
ModelMenu,
|
||||
},
|
||||
mixins: [CardMixin, ApiMixin],
|
||||
components: { GenericHorizontalCard, GenericModalForm, GenericInfiniteCards, ModelMenu },
|
||||
data() {
|
||||
return {
|
||||
// this.Models and this.Actions inherited from ApiMixin
|
||||
@@ -236,6 +196,7 @@ export default {
|
||||
}
|
||||
},
|
||||
finishAction: function (e) {
|
||||
let update = undefined
|
||||
switch (e?.action) {
|
||||
case "save":
|
||||
this.saveThis(e.form_data)
|
||||
@@ -263,7 +224,7 @@ export default {
|
||||
}
|
||||
this.clearState()
|
||||
},
|
||||
getItems: function (params, col) {
|
||||
getItems: function (params = {}, col) {
|
||||
let column = col || "left"
|
||||
params.options = { query: { extended: 1 } } // returns extended values in API response
|
||||
this.genericAPI(this.this_model, this.Actions.LIST, params)
|
||||
|
||||
@@ -629,7 +629,6 @@ export default {
|
||||
|
||||
apiFactory.updateRecipe(this.recipe_id, this.recipe,
|
||||
{}).then((response) => {
|
||||
console.log(response)
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||
this.recipe_changed = false
|
||||
if (view_after) {
|
||||
|
||||
@@ -7,11 +7,7 @@
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-10 col-xl-8 mt-3 mb-3">
|
||||
<b-input-group>
|
||||
<b-input
|
||||
class="form-control form-control-lg form-control-borderless form-control-search"
|
||||
v-model="settings.search_input"
|
||||
v-bind:placeholder="$t('Search')"
|
||||
></b-input>
|
||||
<b-input class="form-control form-control-lg form-control-borderless form-control-search" v-model="settings.search_input" v-bind:placeholder="$t('Search')"></b-input>
|
||||
<b-input-group-append>
|
||||
<b-button v-b-tooltip.hover :title="$t('show_sql')" @click="showSQL()">
|
||||
<i class="fas fa-bug" style="font-size: 1.5em"></i>
|
||||
@@ -19,12 +15,7 @@
|
||||
<b-button variant="light" v-b-tooltip.hover :title="$t('Random Recipes')" @click="openRandom()">
|
||||
<i class="fas fa-dice-five" style="font-size: 1.5em"></i>
|
||||
</b-button>
|
||||
<b-button
|
||||
v-b-toggle.collapse_advanced_search
|
||||
v-b-tooltip.hover
|
||||
:title="$t('Advanced Settings')"
|
||||
v-bind:variant="!isAdvancedSettingsSet() ? 'primary' : 'danger'"
|
||||
>
|
||||
<b-button v-b-toggle.collapse_advanced_search v-b-tooltip.hover :title="$t('Advanced Settings')" v-bind:variant="!isAdvancedSettingsSet() ? 'primary' : 'danger'">
|
||||
<!-- TODO consider changing this icon to a filter -->
|
||||
<i class="fas fa-caret-down" v-if="!settings.advanced_search_visible"></i>
|
||||
<i class="fas fa-caret-up" v-if="settings.advanced_search_visible"></i>
|
||||
@@ -78,13 +69,7 @@
|
||||
<b-form-checkbox switch v-model="settings.show_meal_plan" id="popover-input-2" size="sm"></b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
v-if="settings.show_meal_plan"
|
||||
v-bind:label="$t('Meal_Plan_Days')"
|
||||
label-for="popover-input-5"
|
||||
label-cols="6"
|
||||
class="mb-3"
|
||||
>
|
||||
<b-form-group v-if="settings.show_meal_plan" v-bind:label="$t('Meal_Plan_Days')" label-for="popover-input-5" label-cols="6" class="mb-3">
|
||||
<b-form-input type="number" v-model="settings.meal_plan_days" id="popover-input-5" size="sm"></b-form-input>
|
||||
</b-form-group>
|
||||
|
||||
@@ -99,9 +84,7 @@
|
||||
</div>
|
||||
<div class="row" style="margin-top: 1vh">
|
||||
<div class="col-12" style="text-align: right">
|
||||
<b-button size="sm" variant="secondary" style="margin-right: 8px" @click="$root.$emit('bv::hide::popover')"
|
||||
>{{ $t("Close") }}
|
||||
</b-button>
|
||||
<b-button size="sm" variant="secondary" style="margin-right: 8px" @click="$root.$emit('bv::hide::popover')">{{ $t("Close") }} </b-button>
|
||||
</div>
|
||||
</div>
|
||||
</b-popover>
|
||||
@@ -123,13 +106,7 @@
|
||||
/>
|
||||
<b-input-group-append>
|
||||
<b-input-group-text>
|
||||
<b-form-checkbox
|
||||
v-model="settings.search_keywords_or"
|
||||
name="check-button"
|
||||
@change="refreshData(false)"
|
||||
class="shadow-none"
|
||||
switch
|
||||
>
|
||||
<b-form-checkbox v-model="settings.search_keywords_or" name="check-button" @change="refreshData(false)" class="shadow-none" switch>
|
||||
<span class="text-uppercase" v-if="settings.search_keywords_or">{{ $t("or") }}</span>
|
||||
<span class="text-uppercase" v-else>{{ $t("and") }}</span>
|
||||
</b-form-checkbox>
|
||||
@@ -156,13 +133,7 @@
|
||||
/>
|
||||
<b-input-group-append>
|
||||
<b-input-group-text>
|
||||
<b-form-checkbox
|
||||
v-model="settings.search_foods_or"
|
||||
name="check-button"
|
||||
@change="refreshData(false)"
|
||||
class="shadow-none"
|
||||
switch
|
||||
>
|
||||
<b-form-checkbox v-model="settings.search_foods_or" name="check-button" @change="refreshData(false)" class="shadow-none" switch>
|
||||
<span class="text-uppercase" v-if="settings.search_foods_or">{{ $t("or") }}</span>
|
||||
<span class="text-uppercase" v-else>{{ $t("and") }}</span>
|
||||
</b-form-checkbox>
|
||||
@@ -187,14 +158,7 @@
|
||||
></generic-multiselect>
|
||||
<b-input-group-append>
|
||||
<b-input-group-text>
|
||||
<b-form-checkbox
|
||||
v-model="settings.search_books_or"
|
||||
name="check-button"
|
||||
@change="refreshData(false)"
|
||||
class="shadow-none"
|
||||
tyle="width: 100%"
|
||||
switch
|
||||
>
|
||||
<b-form-checkbox v-model="settings.search_books_or" name="check-button" @change="refreshData(false)" class="shadow-none" tyle="width: 100%" switch>
|
||||
<span class="text-uppercase" v-if="settings.search_books_or">{{ $t("or") }}</span>
|
||||
<span class="text-uppercase" v-else>{{ $t("and") }}</span>
|
||||
</b-form-checkbox>
|
||||
@@ -242,14 +206,7 @@
|
||||
<div class="col col-md-12">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-gap: 0.8rem">
|
||||
<template v-if="!searchFiltered">
|
||||
<recipe-card
|
||||
v-bind:key="`mp_${m.id}`"
|
||||
v-for="m in meal_plans"
|
||||
:recipe="m.recipe"
|
||||
:meal_plan="m"
|
||||
:footer_text="m.meal_type_name"
|
||||
footer_icon="far fa-calendar-alt"
|
||||
></recipe-card>
|
||||
<recipe-card v-bind:key="`mp_${m.id}`" v-for="m in meal_plans" :recipe="m.recipe" :meal_plan="m" :footer_text="m.meal_type_name" footer_icon="far fa-calendar-alt"></recipe-card>
|
||||
</template>
|
||||
<recipe-card v-for="r in recipes" v-bind:key="r.id" :recipe="r" :footer_text="isRecentOrNew(r)[0]" :footer_icon="isRecentOrNew(r)[1]"> </recipe-card>
|
||||
</div>
|
||||
@@ -258,8 +215,7 @@
|
||||
|
||||
<div class="row" style="margin-top: 2vh" v-if="!random_search">
|
||||
<div class="col col-md-12">
|
||||
<b-pagination pills v-model="settings.pagination_page" :total-rows="pagination_count" :per-page="settings.page_count" @change="pageChange" align="center">
|
||||
</b-pagination>
|
||||
<b-pagination pills v-model="settings.pagination_page" :total-rows="pagination_count" :per-page="settings.page_count" @change="pageChange" align="center"> </b-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,90 +1,61 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<template v-if="loading">
|
||||
<loading-spinner></loading-spinner>
|
||||
</template>
|
||||
<div id="app">
|
||||
<template v-if="loading">
|
||||
<loading-spinner></loading-spinner>
|
||||
</template>
|
||||
|
||||
<div v-if="!loading">
|
||||
<div class="row">
|
||||
<div class="col-12" style="text-align: center">
|
||||
<h3>{{ recipe.name }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row text-center">
|
||||
<div class="col col-md-12">
|
||||
<recipe-rating :recipe="recipe"></recipe-rating>
|
||||
<last-cooked :recipe="recipe" class="mt-2"></last-cooked>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-auto">
|
||||
<div class="col-12" style="text-align: center">
|
||||
<i>{{ recipe.description }}</i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center">
|
||||
<keywords-component :recipe="recipe"></keywords-component>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
<div class="row">
|
||||
<div class="col col-md-3">
|
||||
<div class="row d-flex" style="padding-left: 16px">
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<i class="fas fa-user-clock fa-2x text-primary"></i>
|
||||
<div v-if="!loading">
|
||||
<div class="row">
|
||||
<div class="col-12" style="text-align: center">
|
||||
<h3>{{ recipe.name }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<span class="text-primary"><b>{{ $t('Preparation') }}</b></span><br/>
|
||||
{{ recipe.working_time }} {{ $t('min') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-3">
|
||||
<div class="row d-flex">
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<i class="far fa-clock fa-2x text-primary"></i>
|
||||
<div class="row text-center">
|
||||
<div class="col col-md-12">
|
||||
<recipe-rating :recipe="recipe"></recipe-rating>
|
||||
<last-cooked :recipe="recipe" class="mt-2"></last-cooked>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<span class="text-primary"><b>{{ $t('Waiting') }}</b></span><br/>
|
||||
{{ recipe.waiting_time }} {{ $t('min') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-4 col-10 mt-2 mt-md-0 mt-lg-0 mt-xl-0">
|
||||
<div class="row d-flex" style="padding-left: 16px">
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<i class="fas fa-pizza-slice fa-2x text-primary"></i>
|
||||
<div class="my-auto">
|
||||
<div class="col-12" style="text-align: center">
|
||||
<i>{{ recipe.description }}</i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<input
|
||||
style="text-align: right; border-width:0px;border:none; padding:0px; padding-left: 0.5vw; padding-right: 8px; max-width: 80px"
|
||||
value="1" maxlength="3" min="0"
|
||||
type="number" class="form-control form-control-lg" v-model.number="servings"/>
|
||||
</div>
|
||||
<div class="my-auto ">
|
||||
<span class="text-primary"><b><template v-if="recipe.servings_text === ''">{{ $t('Servings') }}</template><template
|
||||
v-else>{{ recipe.servings_text }}</template></b></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-2 col-2 my-auto" style="text-align: right; padding-right: 1vw">
|
||||
<recipe-context-menu v-bind:recipe="recipe" :servings="servings"></recipe-context-menu>
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
<div style="text-align: center">
|
||||
<keywords :recipe="recipe"></keywords>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 order-md-1 col-sm-12 order-sm-2 col-12 order-2" v-if="recipe && ingredient_count > 0">
|
||||
<div class="card border-primary">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col col-md-8">
|
||||
<h4 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t('Ingredients') }}</h4>
|
||||
<hr />
|
||||
<div class="row">
|
||||
<div class="col col-md-3">
|
||||
<div class="row d-flex" style="padding-left: 16px">
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<i class="fas fa-user-clock fa-2x text-primary"></i>
|
||||
</div>
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<span class="text-primary"
|
||||
><b>{{ $t("Preparation") }}</b></span
|
||||
><br />
|
||||
{{ recipe.working_time }} {{ $t("min") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-3">
|
||||
<div class="row d-flex">
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<i class="far fa-clock fa-2x text-primary"></i>
|
||||
</div>
|
||||
<div class="my-auto" style="padding-right: 4px">
|
||||
<span class="text-primary"
|
||||
><b>{{ $t("Waiting") }}</b></span
|
||||
><br />
|
||||
{{ recipe.waiting_time }} {{ $t("min") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br/>
|
||||
@@ -94,184 +65,186 @@
|
||||
<template v-if="s.show_as_header && s.name !== '' && s.ingredients.length > 0">
|
||||
<b v-bind:key="s.id">{{s.name}}</b>
|
||||
</template>
|
||||
<table class="table table-sm">
|
||||
<table class="table table-sm">
|
||||
<template v-for="i in s.ingredients" :key="i.id">
|
||||
<ingredient-component :ingredient="i" :ingredient_factor="ingredient_factor"
|
||||
@checked-state-changed="updateIngredientCheckedState"></ingredient-component>
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-2 col-2 my-auto" style="text-align: right; padding-right: 1vw">
|
||||
<recipe-context-menu v-bind:recipe="recipe" :servings="servings"></recipe-context-menu>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div class="col-12 order-1 col-sm-12 order-sm-1 col-md-6 order-md-2">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="row">
|
||||
<div class="col-md-8 order-md-1 col-sm-12 order-sm-2 col-12 order-2" v-if="recipe && ingredient_count > 0">
|
||||
<ingredients-card
|
||||
:steps="recipe.steps"
|
||||
:recipe="recipe.id"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:servings="servings"
|
||||
:header="true"
|
||||
@checked-state-changed="updateIngredientCheckedState"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<img class="img img-fluid rounded" :src="recipe.image" style="max-height: 30vh;"
|
||||
:alt="$t( 'Recipe_Image')" v-if="recipe.image !== null">
|
||||
<div class="col-12 order-1 col-sm-12 order-sm-1 col-md-4 order-md-2">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<img class="img img-fluid rounded" :src="recipe.image" style="max-height: 30vh" :alt="$t('Recipe_Image')" v-if="recipe.image !== null" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" style="margin-top: 2vh; margin-bottom: 2vh">
|
||||
<div class="col-12">
|
||||
<Nutrition :recipe="recipe" :ingredient_factor="ingredient_factor"></Nutrition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" style="margin-top: 2vh; margin-bottom: 2vh">
|
||||
<div class="col-12">
|
||||
<Nutrition-component :recipe="recipe" :ingredient_factor="ingredient_factor"></Nutrition-component>
|
||||
<template v-if="!recipe.internal">
|
||||
<div v-if="recipe.file_path.includes('.pdf')">
|
||||
<PdfViewer :recipe="recipe"></PdfViewer>
|
||||
</div>
|
||||
<div v-if="recipe.file_path.includes('.png') || recipe.file_path.includes('.jpg') || recipe.file_path.includes('.jpeg') || recipe.file_path.includes('.gif')">
|
||||
<ImageViewer :recipe="recipe"></ImageViewer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-for="(s, index) in recipe.steps" v-bind:key="s.id" style="margin-top: 1vh">
|
||||
<Step
|
||||
:recipe="recipe"
|
||||
:step="s"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:index="index"
|
||||
:start_time="start_time"
|
||||
@update-start-time="updateStartTime"
|
||||
@checked-state-changed="updateIngredientCheckedState"
|
||||
></Step>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<add-recipe-to-book :recipe="recipe"></add-recipe-to-book>
|
||||
|
||||
</div>
|
||||
|
||||
<template v-if="!recipe.internal">
|
||||
<div v-if="recipe.file_path.includes('.pdf')">
|
||||
<PdfViewer :recipe="recipe"></PdfViewer>
|
||||
<div class="row text-center d-print-none" style="margin-top: 3vh; margin-bottom: 3vh" v-if="share_uid !== 'None'">
|
||||
<div class="col col-md-12">
|
||||
<a :href="resolveDjangoUrl('view_report_share_abuse', share_uid)">{{ $t("Report Abuse") }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="recipe.file_path.includes('.png') || recipe.file_path.includes('.jpg') || recipe.file_path.includes('.jpeg') || recipe.file_path.includes('.gif')">
|
||||
<ImageViewer :recipe="recipe"></ImageViewer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<div v-for="(s, index) in recipe.steps" v-bind:key="s.id" style="margin-top: 1vh">
|
||||
<step-component :recipe="recipe" :step="s" :ingredient_factor="ingredient_factor" :index="index" :start_time="start_time"
|
||||
@update-start-time="updateStartTime" @checked-state-changed="updateIngredientCheckedState"></step-component>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<add-recipe-to-book :recipe="recipe"></add-recipe-to-book>
|
||||
|
||||
<div class="row text-center d-print-none" style="margin-top: 3vh; margin-bottom: 3vh" v-if="share_uid !== 'None'">
|
||||
<div class="col col-md-12">
|
||||
<a :href="resolveDjangoUrl('view_report_share_abuse', share_uid)">{{ $t('Report Abuse') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue'
|
||||
import {BootstrapVue} from 'bootstrap-vue'
|
||||
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
||||
import Vue from "vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
|
||||
import {apiLoadRecipe} from "@/utils/api";
|
||||
import { apiLoadRecipe } from "@/utils/api"
|
||||
|
||||
import Step from "@/components/StepComponent";
|
||||
import RecipeContextMenu from "@/components/RecipeContextMenu";
|
||||
import {ResolveUrlMixin, ToastMixin} from "@/utils/utils";
|
||||
import Ingredient from "@/components/IngredientComponent";
|
||||
import Step from "@/components/Step"
|
||||
import RecipeContextMenu from "@/components/ContextMenu/RecipeContextMenu"
|
||||
import { ResolveUrlMixin, ToastMixin } from "@/utils/utils"
|
||||
import IngredientsCard from "@/components/IngredientsCard"
|
||||
|
||||
import PdfViewer from "@/components/PdfViewer";
|
||||
import ImageViewer from "@/components/ImageViewer";
|
||||
import Nutrition from "@/components/NutritionComponent";
|
||||
import PdfViewer from "@/components/PdfViewer"
|
||||
import ImageViewer from "@/components/ImageViewer"
|
||||
import Nutrition from "@/components/Nutrition"
|
||||
|
||||
import moment from 'moment'
|
||||
import Keywords from "@/components/KeywordsComponent";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
import AddRecipeToBook from "@/components/AddRecipeToBook";
|
||||
import RecipeRating from "@/components/RecipeRating";
|
||||
import LastCooked from "@/components/LastCooked";
|
||||
import IngredientComponent from "@/components/IngredientComponent";
|
||||
import StepComponent from "@/components/StepComponent";
|
||||
import KeywordsComponent from "@/components/KeywordsComponent";
|
||||
import NutritionComponent from "@/components/NutritionComponent";
|
||||
import moment from "moment"
|
||||
import Keywords from "@/components/Keywords"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import AddRecipeToBook from "@/components/Modals/AddRecipeToBook"
|
||||
import RecipeRating from "@/components/RecipeRating"
|
||||
import LastCooked from "@/components/LastCooked"
|
||||
|
||||
Vue.prototype.moment = moment
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: 'RecipeView',
|
||||
mixins: [
|
||||
ResolveUrlMixin,
|
||||
ToastMixin,
|
||||
],
|
||||
components: {
|
||||
LastCooked,
|
||||
RecipeRating,
|
||||
PdfViewer,
|
||||
ImageViewer,
|
||||
IngredientComponent,
|
||||
StepComponent,
|
||||
RecipeContextMenu,
|
||||
NutritionComponent,
|
||||
KeywordsComponent,
|
||||
LoadingSpinner,
|
||||
AddRecipeToBook,
|
||||
},
|
||||
computed: {
|
||||
ingredient_factor: function () {
|
||||
return this.servings / this.recipe.servings
|
||||
name: "RecipeView",
|
||||
mixins: [ResolveUrlMixin, ToastMixin],
|
||||
components: {
|
||||
LastCooked,
|
||||
RecipeRating,
|
||||
PdfViewer,
|
||||
ImageViewer,
|
||||
IngredientsCard,
|
||||
Step,
|
||||
RecipeContextMenu,
|
||||
Nutrition,
|
||||
Keywords,
|
||||
LoadingSpinner,
|
||||
AddRecipeToBook,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
recipe: undefined,
|
||||
ingredient_count: 0,
|
||||
servings: 1,
|
||||
start_time: "",
|
||||
share_uid: window.SHARE_UID
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadRecipe(window.RECIPE_ID)
|
||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||
},
|
||||
methods: {
|
||||
loadRecipe: function (recipe_id) {
|
||||
apiLoadRecipe(recipe_id).then(recipe => {
|
||||
|
||||
if (window.USER_SERVINGS !== 0) {
|
||||
recipe.servings = window.USER_SERVINGS
|
||||
}
|
||||
this.servings = recipe.servings
|
||||
|
||||
let total_time = 0
|
||||
for (let step of recipe.steps) {
|
||||
this.ingredient_count += step.ingredients.length
|
||||
|
||||
for (let ingredient of step.ingredients) {
|
||||
this.$set(ingredient, 'checked', false)
|
||||
}
|
||||
|
||||
step.time_offset = total_time
|
||||
total_time += step.time
|
||||
}
|
||||
|
||||
// set start time only if there are any steps with timers (otherwise no timers are rendered)
|
||||
if (total_time > 0) {
|
||||
this.start_time = moment().format('yyyy-MM-DDTHH:mm')
|
||||
}
|
||||
|
||||
this.recipe = recipe
|
||||
this.loading = false
|
||||
})
|
||||
computed: {
|
||||
ingredient_factor: function () {
|
||||
return this.servings / this.recipe.servings
|
||||
},
|
||||
},
|
||||
updateStartTime: function (e) {
|
||||
this.start_time = e
|
||||
},
|
||||
updateIngredientCheckedState: function (e) {
|
||||
for (let step of this.recipe.steps) {
|
||||
for (let ingredient of step.ingredients) {
|
||||
if (ingredient.id === e.id) {
|
||||
this.$set(ingredient, 'checked', !ingredient.checked)
|
||||
}
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
recipe: undefined,
|
||||
ingredient_count: 0,
|
||||
servings: 1,
|
||||
start_time: "",
|
||||
share_uid: window.SHARE_UID,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
mounted() {
|
||||
this.loadRecipe(window.RECIPE_ID)
|
||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||
},
|
||||
methods: {
|
||||
loadRecipe: function (recipe_id) {
|
||||
apiLoadRecipe(recipe_id).then((recipe) => {
|
||||
if (window.USER_SERVINGS !== 0) {
|
||||
recipe.servings = window.USER_SERVINGS
|
||||
}
|
||||
this.servings = recipe.servings
|
||||
|
||||
let total_time = 0
|
||||
for (let step of recipe.steps) {
|
||||
this.ingredient_count += step.ingredients.length
|
||||
|
||||
for (let ingredient of step.ingredients) {
|
||||
this.$set(ingredient, "checked", false)
|
||||
}
|
||||
|
||||
step.time_offset = total_time
|
||||
total_time += step.time
|
||||
}
|
||||
|
||||
// set start time only if there are any steps with timers (otherwise no timers are rendered)
|
||||
if (total_time > 0) {
|
||||
this.start_time = moment().format("yyyy-MM-DDTHH:mm")
|
||||
}
|
||||
|
||||
this.recipe = recipe
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
updateStartTime: function (e) {
|
||||
this.start_time = e
|
||||
},
|
||||
|
||||
updateIngredientCheckedState: function (e) {
|
||||
for (let step of this.recipe.steps) {
|
||||
for (let ingredient of step.ingredients) {
|
||||
if (ingredient.id === e.id) {
|
||||
this.$set(ingredient, "checked", !ingredient.checked)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
|
||||
</style>
|
||||
<style></style>
|
||||
|
||||
805
vue/src/apps/ShoppingListView/ShoppingListView.vue
Normal file
805
vue/src/apps/ShoppingListView/ShoppingListView.vue
Normal file
@@ -0,0 +1,805 @@
|
||||
<template>
|
||||
<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="offset-md-10 col-md-2 no-gutter text-right">
|
||||
<b-button variant="link" class="px-0">
|
||||
<i class="btn fas fa-plus-circle fa-lg px-0" @click="entrymode = !entrymode" :class="entrymode ? 'text-success' : 'text-muted'" />
|
||||
</b-button>
|
||||
<b-button variant="link" id="id_filters_button" class="px-0">
|
||||
<i class="btn fas fa-filter text-decoration-none fa-lg px-0" :class="filterApplied ? 'text-danger' : 'text-muted'" />
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-tabs content-class="mt-3">
|
||||
<!-- shopping list tab -->
|
||||
<b-tab :title="$t('ShoppingList')" active>
|
||||
<template #title> <b-spinner v-if="loading" type="border" small></b-spinner> {{ $t("ShoppingList") }} </template>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div role="tablist" v-if="items && items.length > 0">
|
||||
<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-3">
|
||||
<generic-multiselect
|
||||
@change="new_item.unit = $event.val"
|
||||
:model="Models.UNIT"
|
||||
:multiple="false"
|
||||
:allow_create="false"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
:placeholder="$t('Unit')"
|
||||
/>
|
||||
</div>
|
||||
<div class="col col-md-4">
|
||||
<generic-multiselect
|
||||
@change="new_item.food = $event.val"
|
||||
:model="Models.FOOD"
|
||||
:multiple="false"
|
||||
:allow_create="false"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
:placeholder="$t('Food')"
|
||||
/>
|
||||
</div>
|
||||
<div class="col col-md-1 ">
|
||||
<b-button variant="link" class="px-0">
|
||||
<i class="btn fas fa-cart-plus fa-lg px-0 text-success" @click="addItem" />
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
<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 v-if="Object.entries(s).length > 0">
|
||||
<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" @update-checkbox="updateChecked" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</b-tab>
|
||||
<!-- recipe tab -->
|
||||
<b-tab :title="$t('Recipes')">
|
||||
<table class="table w-75">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{ $t("Meal_Plan") }}</th>
|
||||
<th scope="col">{{ $t("Recipe") }}</th>
|
||||
<th scope="col">{{ $t("Servings") }}</th>
|
||||
<th scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr v-for="r in Recipes" :key="r.list_recipe">
|
||||
<td>{{ r.recipe_mealplan.name }}</td>
|
||||
<td>{{ r.recipe_mealplan.recipe_name }}</td>
|
||||
<td class="block-inline">
|
||||
<b-form-input min="1" type="number" :debounce="300" :value="r.recipe_mealplan.servings" @input="updateServings($event, r.list_recipe)"></b-form-input>
|
||||
</td>
|
||||
<td>
|
||||
<i class="btn text-danger fas fa-trash fa-lg px-2 border-0" variant="link" :title="$t('Delete')" @click="deleteRecipe($event, r.list_recipe)" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</b-tab>
|
||||
<!-- settings tab -->
|
||||
<b-tab :title="$t('Settings')">
|
||||
<div class="row">
|
||||
<div class="col col-md-4 ">
|
||||
<b-card class="no-body">
|
||||
<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_autoadd_shopping" @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 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-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-1">
|
||||
<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-1">
|
||||
<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('ShowDelayed')" label-for="popover-input-3" content-cols="1" class="mb-1">
|
||||
<b-form-checkbox v-model="show_delay"></b-form-checkbox>
|
||||
</b-form-group>
|
||||
<b-form-group v-bind:label="$t('ShowUncategorizedFood')" label-for="popover-input-4" content-cols="1" class="mb-1" v-if="!selected_supermarket">
|
||||
<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-5" content-cols="1" class="mb-1" v-if="selected_supermarket">
|
||||
<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="primary" class="mx-1" @click="resetFilters">{{ $t("Reset") }} </b-button>
|
||||
<b-button size="sm" variant="secondary" class="mr-3" @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="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>
|
||||
<span @click.prevent.stop @mouseup.prevent.stop>
|
||||
<!-- would like to hide the dropdown value and only display value in button - not sure how to do that -->
|
||||
<b-form-select class="mt-2 border-0" :options="shopping_categories" text-field="name" value-field="id" v-model="shopcat"></b-form-select>
|
||||
</span>
|
||||
</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>
|
||||
<div @click.prevent.stop>
|
||||
<b-form-input class="mt-2" min="0" type="number" v-model="delay"></b-form-input>
|
||||
</div>
|
||||
</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 GenericMultiselect from "@/components/GenericMultiselect"
|
||||
|
||||
import { ApiMixin, getUserPreference } from "@/utils/utils"
|
||||
import { ApiApiFactory } from "@/utils/openapi/api"
|
||||
import { StandardToasts, makeToast } from "@/utils/utils"
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: "ShoppingListView",
|
||||
mixins: [ApiMixin],
|
||||
components: { ContextMenu, ContextMenuItem, ShoppingLineItem, GenericMultiselect },
|
||||
|
||||
data() {
|
||||
return {
|
||||
// this.Models and this.Actions inherited from ApiMixin
|
||||
items: [],
|
||||
group_by: "category",
|
||||
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,
|
||||
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_modal: false,
|
||||
fields: ["checked", "amount", "category", "unit", "food", "recipe", "details"],
|
||||
loading: true,
|
||||
entrymode: false,
|
||||
new_item: { amount: 1, unit: undefined, food: undefined },
|
||||
online: 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
|
||||
}
|
||||
}
|
||||
|
||||
let shopping_list = this.items
|
||||
|
||||
// filter out list items that are delayed
|
||||
if (!this.show_delay && shopping_list) {
|
||||
shopping_list = shopping_list.filter((x) => !x.delay_until || !Date.parse(x?.delay_until) > new Date(Date.now()))
|
||||
}
|
||||
|
||||
// if a supermarket is selected and filtered to only supermarket categories filter out everything else
|
||||
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))
|
||||
// if showing undefined is off, filter undefined
|
||||
} else if (!this.show_undefined_categories) {
|
||||
shopping_list = shopping_list.filter((x) => x?.food?.supermarket_category)
|
||||
}
|
||||
|
||||
let groups = { false: {}, true: {} } // force unchecked to always be first
|
||||
if (this.selected_supermarket) {
|
||||
let super_cats = this.supermarkets
|
||||
.filter((x) => x.id === this.selected_supermarket)
|
||||
.map((x) => x.category_to_supermarket)
|
||||
.flat()
|
||||
.map((x) => x.category.name)
|
||||
new Set([...super_cats, ...this.shopping_categories.map((x) => x.name)]).forEach((cat) => {
|
||||
groups["false"][cat.name] = {}
|
||||
groups["true"][cat.name] = {}
|
||||
})
|
||||
} else {
|
||||
this.shopping_categories.forEach((cat) => {
|
||||
groups.false[cat.name] = {}
|
||||
groups.true[cat.name] = {}
|
||||
})
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
})
|
||||
return groups
|
||||
},
|
||||
defaultDelay() {
|
||||
return getUserPreference("default_delay") || 2
|
||||
},
|
||||
itemsDelayed() {
|
||||
return this.items.filter((x) => !x.delay_until || !Date.parse(x?.delay_until) > new Date(Date.now())).length < this.items.length
|
||||
},
|
||||
filterApplied() {
|
||||
return (this.itemsDelayed && !this.show_delay) || !this.show_undefined_categories || (this.supermarket_categories_only && this.selected_supermarket)
|
||||
},
|
||||
Recipes() {
|
||||
return [...new Map(this.items.filter((x) => x.list_recipe).map((item) => [item["list_recipe"], item])).values()]
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selected_supermarket(newVal, oldVal) {
|
||||
this.supermarket_categories_only = this.settings.filter_to_supermarket
|
||||
},
|
||||
"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() {
|
||||
this.getShoppingList()
|
||||
this.getSupermarkets()
|
||||
this.getShoppingCategories()
|
||||
|
||||
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: {
|
||||
// this.genericAPI inherited from ApiMixin
|
||||
addItem() {
|
||||
let api = new ApiApiFactory()
|
||||
api.createShoppingListEntry(this.new_item)
|
||||
.then((results) => {
|
||||
if (results?.data) {
|
||||
this.items.push(results.data)
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
} else {
|
||||
console.log("no data returned")
|
||||
}
|
||||
this.new_item = { amount: 1 }
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||
})
|
||||
},
|
||||
categoryName: function(value) {
|
||||
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) {
|
||||
let entries = []
|
||||
let promises = []
|
||||
if (Array.isArray(item)) {
|
||||
entries = item.map((x) => x.id)
|
||||
} else {
|
||||
entries = [item.id]
|
||||
}
|
||||
let delay_date = new Date(Date.now() + this.delay * (60 * 60 * 1000))
|
||||
|
||||
entries.forEach((entry) => {
|
||||
promises.push(this.saveThis({ id: entry, delay_until: delay_date }, false))
|
||||
})
|
||||
Promise.all(promises).then(() => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||
this.delay = this.defaultDelay
|
||||
})
|
||||
},
|
||||
deleteRecipe: function(e, recipe) {
|
||||
let api = new ApiApiFactory()
|
||||
api.destroyShoppingListRecipe(recipe)
|
||||
.then((x) => {
|
||||
this.items = this.items.filter((x) => x.list_recipe !== recipe)
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
|
||||
})
|
||||
},
|
||||
deleteThis: function(item) {
|
||||
let api = new ApiApiFactory()
|
||||
let entries = []
|
||||
let promises = []
|
||||
if (Array.isArray(item)) {
|
||||
entries = item.map((x) => x.id)
|
||||
} else {
|
||||
entries = [item.id]
|
||||
}
|
||||
|
||||
entries.forEach((x) => {
|
||||
promises.push(
|
||||
api.destroyShoppingListEntry(x).catch((err) => {
|
||||
console.log(err)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
Promise.all(promises).then((result) => {
|
||||
this.items = this.items.filter((x) => !entries.includes(x.id))
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||
})
|
||||
},
|
||||
foodName: function(value) {
|
||||
return value?.food?.name ?? value?.[0]?.food?.name ?? ""
|
||||
},
|
||||
getShoppingCategories: function() {
|
||||
let api = new ApiApiFactory()
|
||||
api.listSupermarketCategorys().then((result) => {
|
||||
this.shopping_categories = result.data
|
||||
})
|
||||
},
|
||||
getShoppingList: function(autosync = false) {
|
||||
let params = {}
|
||||
params.supermarket = this.selected_supermarket
|
||||
|
||||
params.options = { query: { recent: 1 } }
|
||||
if (autosync) {
|
||||
params.options.query["autosync"] = 1
|
||||
} else {
|
||||
this.loading = true
|
||||
}
|
||||
this.genericAPI(this.Models.SHOPPING_LIST, this.Actions.LIST, params)
|
||||
.then((results) => {
|
||||
if (!autosync) {
|
||||
if (results.data?.length) {
|
||||
this.items = results.data
|
||||
} else {
|
||||
console.log("no data returned")
|
||||
}
|
||||
this.loading = false
|
||||
} else {
|
||||
this.mergeShoppingList(results.data)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
if (!autosync) {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
|
||||
}
|
||||
})
|
||||
},
|
||||
getSupermarkets: function() {
|
||||
let api = new ApiApiFactory()
|
||||
api.listSupermarkets().then((result) => {
|
||||
this.supermarkets = result.data
|
||||
})
|
||||
},
|
||||
getThis: function(id) {
|
||||
return this.genericAPI(this.Models.SHOPPING_CATEGORY, this.Actions.FETCH, { id: id })
|
||||
},
|
||||
ignoreThis: function(item) {
|
||||
let food = {
|
||||
id: item?.[0]?.food.id ?? item.food.id,
|
||||
ignore_shopping: true,
|
||||
}
|
||||
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) {
|
||||
if (!e) {
|
||||
makeToast(this.$t("Warning"), this.$t("NoCategory"), "warning")
|
||||
}
|
||||
|
||||
// TODO make decision - should inheritance always be turned off when category set manually or give user a choice at front-end or make it a setting?
|
||||
let food = this.items.filter((x) => x.food.id == item?.[0]?.food.id ?? item.food.id)[0].food
|
||||
food.supermarket_category = this.shopping_categories.filter((x) => x?.id === this.shopcat)?.[0]
|
||||
this.updateFood(food, "supermarket_category")
|
||||
this.shopcat = null
|
||||
},
|
||||
onHand: function(item) {
|
||||
let api = new ApiApiFactory()
|
||||
let food = {
|
||||
id: item?.[0]?.food.id ?? item?.food?.id,
|
||||
on_hand: true,
|
||||
}
|
||||
|
||||
this.updateFood(food)
|
||||
.then((result) => {
|
||||
let entries = this.items.filter((x) => x.food.id == food.id).map((x) => x.id)
|
||||
this.items = this.items.filter((x) => x.food.id !== food.id)
|
||||
return entries
|
||||
})
|
||||
.then((entries) => {
|
||||
entries.forEach((x) => {
|
||||
api.destroyShoppingListEntry(x).then((result) => {})
|
||||
})
|
||||
})
|
||||
},
|
||||
openContextMenu(e, value) {
|
||||
this.shopcat = value?.food?.supermarket_category?.id ?? value?.[0]?.food?.supermarket_category?.id ?? undefined
|
||||
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) {
|
||||
let api = new ApiApiFactory()
|
||||
if (!thisItem?.id) {
|
||||
// if there is no item id assume it's a new item
|
||||
return api
|
||||
.createShoppingListEntry(thisItem)
|
||||
.then((result) => {
|
||||
if (toast) {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||
})
|
||||
} else {
|
||||
return api
|
||||
.partialUpdateShoppingListEntry(thisItem.id, thisItem)
|
||||
.then((result) => {
|
||||
if (toast) {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err, err.response)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
})
|
||||
}
|
||||
},
|
||||
sectionID: function(a, b) {
|
||||
return (a + b).replace(/\W/g, "")
|
||||
},
|
||||
updateChecked: function(update) {
|
||||
// when checking a sub item don't refresh the screen until all entries complete but change class to cross out
|
||||
let promises = []
|
||||
update.entries.forEach((x) => {
|
||||
promises.push(this.saveThis({ id: x, checked: update.checked }, false))
|
||||
let item = this.items.filter((entry) => entry.id == x)[0]
|
||||
|
||||
Vue.set(item, "checked", update.checked)
|
||||
if (update.checked) {
|
||||
Vue.set(item, "completed_at", new Date().toISOString())
|
||||
} else {
|
||||
Vue.set(item, "completed_at", undefined)
|
||||
}
|
||||
})
|
||||
Promise.all(promises).catch((err) => {
|
||||
console.log(err, err.response)
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
})
|
||||
},
|
||||
updateFood: function(food, field) {
|
||||
let api = new ApiApiFactory()
|
||||
let ignore_category
|
||||
if (field) {
|
||||
ignore_category = food.ignore_inherit
|
||||
.map((x) => food.ignore_inherit.fields)
|
||||
.flat()
|
||||
.includes(field)
|
||||
} else {
|
||||
ignore_category = true
|
||||
}
|
||||
|
||||
return api
|
||||
.partialUpdateFood(food.id, food)
|
||||
.then((result) => {
|
||||
if (food.inherit && food.supermarket_category && !ignore_category && food.parent) {
|
||||
makeToast(this.$t("Warning"), this.$t("InheritWarning", { food: food.name }), "warning")
|
||||
} else {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||
}
|
||||
if (food?.numchild > 0) {
|
||||
this.getShoppingList() // if food has children, just get the whole list. probably could be more efficient
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err, Object.keys(err))
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
})
|
||||
},
|
||||
updateServings(e, plan) {
|
||||
// maybe this needs debounced?
|
||||
let api = new ApiApiFactory()
|
||||
api.partialUpdateShoppingListRecipe(plan, { id: plan, servings: e }).then(() => {
|
||||
this.getShoppingList()
|
||||
})
|
||||
},
|
||||
updateOnlineStatus(e) {
|
||||
const { type } = e
|
||||
this.online = type === "online"
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener("online", this.updateOnlineStatus)
|
||||
window.removeEventListener("offline", this.updateOnlineStatus)
|
||||
},
|
||||
},
|
||||
}
|
||||
</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;
|
||||
}
|
||||
.float-up {
|
||||
padding-top: -3em;
|
||||
margin-top: -3em;
|
||||
}
|
||||
</style>
|
||||
10
vue/src/apps/ShoppingListView/main.js
Normal file
10
vue/src/apps/ShoppingListView/main.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import i18n from "@/i18n"
|
||||
import Vue from "vue"
|
||||
import App from "./ShoppingListView"
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
new Vue({
|
||||
i18n,
|
||||
render: (h) => h(App),
|
||||
}).$mount("#app")
|
||||
@@ -4,16 +4,22 @@
|
||||
:item="item"/>
|
||||
<icon-badge v-if="Icon"
|
||||
:item="item"/>
|
||||
<on-hand-badge v-if="OnHand"
|
||||
:item="item"/>
|
||||
<shopping-badge v-if="Shopping"
|
||||
:item="item"/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LinkedRecipe from "@/components/Badges/LinkedRecipe";
|
||||
import IconBadge from "@/components/Badges/Icon";
|
||||
import OnHandBadge from "@/components/Badges/OnHand";
|
||||
import ShoppingBadge from "@/components/Badges/Shopping";
|
||||
|
||||
export default {
|
||||
name: 'CardBadges',
|
||||
components: {LinkedRecipe, IconBadge},
|
||||
components: {LinkedRecipe, IconBadge, OnHandBadge, ShoppingBadge},
|
||||
props: {
|
||||
item: {type: Object},
|
||||
model: {type: Object}
|
||||
@@ -30,6 +36,12 @@ export default {
|
||||
},
|
||||
Icon: function () {
|
||||
return this.model?.badges?.icon ?? false
|
||||
},
|
||||
OnHand: function () {
|
||||
return this.model?.badges?.on_hand ?? false
|
||||
},
|
||||
Shopping: function () {
|
||||
return this.model?.badges?.shopping ?? false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<span>
|
||||
<b-button v-if="item.icon" class=" btn p-0 border-0" variant="link">
|
||||
<b-button v-if="item.icon" class=" btn px-1 py-0 border-0 text-decoration-none" variant="link">
|
||||
{{item.icon}}
|
||||
</b-button>
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<span>
|
||||
<b-button v-if="item.recipe" v-b-tooltip.hover :title="item.recipe.name"
|
||||
class=" btn fas fa-book-open p-0 border-0" variant="link" :href="item.recipe.url"/>
|
||||
class=" btn text-decoration-none fas fa-book-open px-1 py-0 border-0" variant="link" :href="item.recipe.url"/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
|
||||
40
vue/src/components/Badges/OnHand.vue
Normal file
40
vue/src/components/Badges/OnHand.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<span>
|
||||
<b-button class="btn text-decoration-none fas px-1 py-0 border-0" variant="link" v-b-popover.hover.html
|
||||
:title="[onhand ? $t('FoodOnHand', {'food': item.name}) : $t('FoodNotOnHand', {'food': item.name})]"
|
||||
:class="[onhand ? 'text-success fa-clipboard-check' : 'text-muted fa-clipboard' ]"
|
||||
@click="toggleOnHand"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import {ApiMixin} from "@/utils/utils";
|
||||
|
||||
export default {
|
||||
name: 'OnHandBadge',
|
||||
props: {
|
||||
item: {type: Object}
|
||||
},
|
||||
mixins: [ ApiMixin ],
|
||||
data() {
|
||||
return {
|
||||
onhand: false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.onhand = this.item.on_hand
|
||||
},
|
||||
watch: {
|
||||
},
|
||||
methods: {
|
||||
toggleOnHand() {
|
||||
let params = {'id': this.item.id, 'on_hand': !this.onhand}
|
||||
this.genericAPI(this.Models.FOOD, this.Actions.UPDATE, params).then(() => {
|
||||
this.onhand = !this.onhand
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
94
vue/src/components/Badges/Shopping.vue
Normal file
94
vue/src/components/Badges/Shopping.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<span>
|
||||
<b-button class="btn text-decoration-none px-1 border-0" variant="link"
|
||||
v-if="ShowBadge"
|
||||
:id="`shopping${item.id}`"
|
||||
@click="addShopping()">
|
||||
<i class="fas"
|
||||
v-b-popover.hover.html
|
||||
:title="[shopping ? $t('RemoveFoodFromShopping', {'food': item.name}) : $t('AddFoodToShopping', {'food': item.name})]"
|
||||
:class="[shopping ? 'text-success fa-shopping-cart' : 'text-muted fa-cart-plus']"
|
||||
/>
|
||||
</b-button>
|
||||
<b-popover :target="`${ShowConfirmation}`" :ref="'shopping'+item.id" triggers="focus" placement="top" >
|
||||
<template #title>{{DeleteConfirmation}}</template>
|
||||
<b-row align-h="end">
|
||||
<b-col cols="auto"><b-button class="btn btn-sm btn-info shadow-none px-1 border-0" @click="cancelDelete()">{{$t("Cancel")}}</b-button>
|
||||
<b-button class="btn btn-sm btn-danger shadow-none px-1" @click="confirmDelete()">{{$t("Confirm")}}</b-button></b-col>
|
||||
</b-row >
|
||||
</b-popover>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {ApiMixin, StandardToasts} from "@/utils/utils";
|
||||
|
||||
export default {
|
||||
name: 'ShoppingBadge',
|
||||
props: {
|
||||
item: {type: Object},
|
||||
override_ignore: {type: Boolean, default: false}
|
||||
},
|
||||
mixins: [ ApiMixin ],
|
||||
data() {
|
||||
return {
|
||||
shopping: false,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// let random = [true, false,]
|
||||
this.shopping = this.item?.shopping //?? random[Math.floor(Math.random() * random.length)]
|
||||
},
|
||||
computed: {
|
||||
ShowBadge() {
|
||||
if (this.override_ignore) {
|
||||
return true
|
||||
} else {
|
||||
return !this.item.ignore_shopping
|
||||
}
|
||||
},
|
||||
DeleteConfirmation() {
|
||||
return this.$t('DeleteShoppingConfirm',{'food':this.item.name})
|
||||
},
|
||||
ShowConfirmation() {
|
||||
if (this.shopping) {
|
||||
return 'shopping' + this.item.id
|
||||
} else {
|
||||
return 'NoDialog'
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
},
|
||||
methods: {
|
||||
addShopping() {
|
||||
if (this.shopping) {return} // if item already in shopping list, excution handled after confirmation
|
||||
let params = {
|
||||
'id': this.item.id,
|
||||
'amount': 1
|
||||
}
|
||||
this.genericAPI(this.Models.FOOD, this.Actions.SHOPPING, params).then((result) => {
|
||||
this.shopping = true
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
})
|
||||
},
|
||||
cancelDelete() {
|
||||
this.$refs['shopping' + this.item.id].$emit('close')
|
||||
},
|
||||
confirmDelete() {
|
||||
let params = {
|
||||
'id': this.item.id,
|
||||
'_delete': 'true'
|
||||
}
|
||||
this.genericAPI(this.Models.FOOD, this.Actions.SHOPPING, params).then(() => {
|
||||
this.shopping = false
|
||||
this.$refs['shopping' + this.item.id].$emit('close')
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -1,127 +1,118 @@
|
||||
<template>
|
||||
<div
|
||||
class="context-menu"
|
||||
ref="popper"
|
||||
v-show="isVisible"
|
||||
tabindex="-1"
|
||||
v-click-outside="close"
|
||||
@contextmenu.capture.prevent>
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<slot :contextData="contextData" name="menu"/>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="context-menu" ref="popper" v-show="isVisible" tabindex="-1" v-click-outside="close" @contextmenu.capture.prevent>
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<slot :contextData="contextData" name="menu" />
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import Popper from 'popper.js';
|
||||
import Popper from "popper.js"
|
||||
|
||||
Popper.Defaults.modifiers.computeStyle.gpuAcceleration = false
|
||||
import ClickOutside from 'vue-click-outside'
|
||||
import ClickOutside from "vue-click-outside"
|
||||
|
||||
export default {
|
||||
name: "ContextMenu.vue",
|
||||
props: {
|
||||
boundariesElement: {
|
||||
type: String,
|
||||
default: 'body',
|
||||
},
|
||||
},
|
||||
components: {},
|
||||
data() {
|
||||
return {
|
||||
opened: false,
|
||||
contextData: {},
|
||||
};
|
||||
},
|
||||
directives: {
|
||||
ClickOutside,
|
||||
},
|
||||
computed: {
|
||||
isVisible() {
|
||||
return this.opened;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
open(evt, contextData) {
|
||||
this.opened = true;
|
||||
this.contextData = contextData;
|
||||
|
||||
if (this.popper) {
|
||||
this.popper.destroy();
|
||||
}
|
||||
|
||||
this.popper = new Popper(this.referenceObject(evt), this.$refs.popper, {
|
||||
placement: 'right-start',
|
||||
modifiers: {
|
||||
preventOverflow: {
|
||||
boundariesElement: document.querySelector(this.boundariesElement),
|
||||
},
|
||||
name: "ContextMenu.vue",
|
||||
props: {
|
||||
boundariesElement: {
|
||||
type: String,
|
||||
default: "body",
|
||||
},
|
||||
});
|
||||
this.$nextTick(() => {
|
||||
this.popper.scheduleUpdate();
|
||||
});
|
||||
|
||||
},
|
||||
close() {
|
||||
this.opened = false;
|
||||
this.contextData = null;
|
||||
},
|
||||
referenceObject(evt) {
|
||||
const left = evt.clientX;
|
||||
const top = evt.clientY;
|
||||
const right = left + 1;
|
||||
const bottom = top + 1;
|
||||
const clientWidth = 1;
|
||||
const clientHeight = 1;
|
||||
|
||||
function getBoundingClientRect() {
|
||||
components: {},
|
||||
data() {
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
};
|
||||
}
|
||||
|
||||
const obj = {
|
||||
getBoundingClientRect,
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
};
|
||||
return obj;
|
||||
opened: false,
|
||||
contextData: {},
|
||||
}
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.popper !== undefined) {
|
||||
this.popper.destroy();
|
||||
}
|
||||
},
|
||||
};
|
||||
directives: {
|
||||
ClickOutside,
|
||||
},
|
||||
computed: {
|
||||
isVisible() {
|
||||
return this.opened
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
open(evt, contextData) {
|
||||
this.opened = true
|
||||
this.contextData = contextData
|
||||
|
||||
if (this.popper) {
|
||||
this.popper.destroy()
|
||||
}
|
||||
|
||||
this.popper = new Popper(this.referenceObject(evt), this.$refs.popper, {
|
||||
placement: "right-start",
|
||||
modifiers: {
|
||||
preventOverflow: {
|
||||
boundariesElement: document.querySelector(this.boundariesElement),
|
||||
},
|
||||
},
|
||||
})
|
||||
this.$nextTick(() => {
|
||||
this.popper.scheduleUpdate()
|
||||
})
|
||||
},
|
||||
close() {
|
||||
this.opened = false
|
||||
this.contextData = null
|
||||
},
|
||||
referenceObject(evt) {
|
||||
const left = evt.clientX
|
||||
const top = evt.clientY
|
||||
const right = left + 1
|
||||
const bottom = top + 1
|
||||
const clientWidth = 1
|
||||
const clientHeight = 1
|
||||
|
||||
function getBoundingClientRect() {
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
}
|
||||
}
|
||||
|
||||
const obj = {
|
||||
getBoundingClientRect,
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
}
|
||||
return obj
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.popper !== undefined) {
|
||||
this.popper.destroy()
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 999;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px 0 #eee;
|
||||
position: fixed;
|
||||
z-index: 999;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px 0 #eee;
|
||||
}
|
||||
|
||||
.context-menu:focus {
|
||||
outline: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.context-menu ul {
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
display: block;
|
||||
position: relative;
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
<template>
|
||||
<li @click="$emit('click', $event)" role="presentation">
|
||||
<slot/>
|
||||
</li>
|
||||
<li @click="$emit('click', $event)" role="presentation">
|
||||
<slot />
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "ContextMenuItem.vue",
|
||||
name: "ContextMenuItem.vue",
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
34
vue/src/components/ContextMenu/ContextMenuSubmenu.vue
Normal file
34
vue/src/components/ContextMenu/ContextMenuSubmenu.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="position: static;" class=" btn-group">
|
||||
<div class="dropdown b-dropdown position-static">
|
||||
<li @click="$refs.submenu.open($event)" role="presentation" class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret">
|
||||
<slot />
|
||||
</li>
|
||||
</div>
|
||||
</div>
|
||||
<ContextMenu ref="submenu">
|
||||
<template #menu="{ contextData }">
|
||||
<ContextMenuItem
|
||||
@click="
|
||||
$refs.menu.close()
|
||||
moveEntry(contextData)
|
||||
"
|
||||
>
|
||||
<a class="dropdown-item p-2" href="#"><i class="fas fa-cubes"></i> submenu item</a>
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ContextMenu from "@/components/ContextMenu/ContextMenu"
|
||||
import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
|
||||
export default {
|
||||
name: "ContextSubmenu.vue",
|
||||
components: { ContextMenu, ContextMenuItem },
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -26,7 +26,11 @@
|
||||
<i class="fas fa-shopping-cart fa-fw"></i> {{ $t('Add_to_Shopping') }}
|
||||
</a>
|
||||
|
||||
<a class="dropdown-item" @click="createMealPlan" href="javascript:void(0);"><i
|
||||
<a class="dropdown-item" v-if="recipe.internal" @click="addToShopping" href="#">
|
||||
<i class="fas fa-shopping-cart fa-fw"></i> New {{ $t('Add_to_Shopping') }}
|
||||
</a>
|
||||
|
||||
<a class="dropdown-item" @click="createMealPlan" href="#"><i
|
||||
class="fas fa-calendar fa-fw"></i> {{ $t('Add_to_Plan') }}
|
||||
</a>
|
||||
|
||||
@@ -76,6 +80,7 @@
|
||||
<meal-plan-edit-modal :entry="entryEditing" :entryEditing_initial_recipe="[recipe]"
|
||||
:entry-editing_initial_meal_type="[]" @save-entry="saveMealPlan"
|
||||
:modal_id="`modal-meal-plan_${modal_id}`" :allow_delete="false" :modal_title="$t('Create_Meal_Plan_Entry')"></meal-plan-edit-modal>
|
||||
<shopping-modal :recipe="recipe" :servings="servings_value" :modal_id="modal_id"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -84,8 +89,9 @@
|
||||
import {makeToast, resolveDjangoUrl, ResolveUrlMixin, StandardToasts} from "@/utils/utils";
|
||||
import CookLog from "@/components/CookLog";
|
||||
import axios from "axios";
|
||||
import AddRecipeToBook from "./AddRecipeToBook";
|
||||
import MealPlanEditModal from "@/components/MealPlanEditModal";
|
||||
import AddRecipeToBook from "@/components/Modals/AddRecipeToBook";
|
||||
import MealPlanEditModal from "@/components/Modals/MealPlanEditModal";
|
||||
import ShoppingModal from "@/components/Modals/ShoppingModal";
|
||||
import moment from "moment";
|
||||
import Vue from "vue";
|
||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||
@@ -100,7 +106,8 @@ export default {
|
||||
components: {
|
||||
AddRecipeToBook,
|
||||
CookLog,
|
||||
MealPlanEditModal
|
||||
MealPlanEditModal,
|
||||
ShoppingModal
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -118,7 +125,7 @@ export default {
|
||||
servings: 1,
|
||||
shared: [],
|
||||
title: '',
|
||||
title_placeholder: this.$t('Title')
|
||||
title_placeholder: this.$t('Title'),
|
||||
}
|
||||
},
|
||||
entryEditing: {},
|
||||
@@ -177,7 +184,10 @@ export default {
|
||||
url: this.recipe_share_link
|
||||
}
|
||||
navigator.share(shareData)
|
||||
}
|
||||
},
|
||||
addToShopping() {
|
||||
this.$bvModal.show(`shopping_${this.modal_id}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -92,13 +92,13 @@
|
||||
<i class="fas fa-times fa-fw"></i> <b>{{$t('Cancel')}}</b>
|
||||
</b-list-group-item>
|
||||
<!-- TODO add to shopping list -->
|
||||
<!-- TODO add to and/or manage pantry -->
|
||||
<!-- TODO toggle onhand -->
|
||||
</b-list-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import GenericContextMenu from "@/components/GenericContextMenu";
|
||||
import GenericContextMenu from "@/components/ContextMenu/GenericContextMenu";
|
||||
import Badges from "@/components/Badges";
|
||||
import GenericPill from "@/components/GenericPill";
|
||||
import GenericOrderedPill from "@/components/GenericOrderedPill";
|
||||
|
||||
@@ -1,88 +1,217 @@
|
||||
<template>
|
||||
|
||||
<tr @click="$emit('checked-state-changed', ingredient)">
|
||||
<template v-if="ingredient.is_header">
|
||||
<td colspan="5">
|
||||
<b>{{ ingredient.note }}</b>
|
||||
</td>
|
||||
</template>
|
||||
<template v-else>
|
||||
<td class="d-print-non" v-if="detailed">
|
||||
<i class="far fa-check-circle text-success" v-if="ingredient.checked"></i>
|
||||
<i class="far fa-check-circle text-primary" v-if="!ingredient.checked"></i>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="ingredient.amount !== 0" v-html="calculateAmount(ingredient.amount)"></span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="ingredient.unit !== null && !ingredient.no_amount">{{ ingredient.unit.name }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="ingredient.food !== null">
|
||||
<a :href="resolveDjangoUrl('view_recipe', ingredient.food.recipe.id)" v-if="ingredient.food.recipe !== null"
|
||||
target="_blank" rel="noopener noreferrer">{{ ingredient.food.name }}</a>
|
||||
<span v-if="ingredient.food.recipe === null">{{ ingredient.food.name }}</span>
|
||||
<tr>
|
||||
<template v-if="ingredient.is_header">
|
||||
<td colspan="5" @click="done">
|
||||
<b>{{ ingredient.note }}</b>
|
||||
</td>
|
||||
</template>
|
||||
</td>
|
||||
<td v-if="detailed">
|
||||
<div v-if="ingredient.note">
|
||||
<span v-b-popover.hover="ingredient.note"
|
||||
class="d-print-none touchable"> <i class="far fa-comment"></i>
|
||||
</span>
|
||||
<!-- v-if="ingredient.note.length > 15" -->
|
||||
<!-- <span v-else>-->
|
||||
<!-- {{ ingredient.note }}-->
|
||||
<!-- </span>-->
|
||||
|
||||
<div class="d-none d-print-block">
|
||||
<i class="far fa-comment-alt d-print-none"></i> {{ ingredient.note }}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
<template v-else>
|
||||
<td class="d-print-non" v-if="detailed && !add_shopping_mode" @click="done">
|
||||
<i class="far fa-check-circle text-success" v-if="ingredient.checked"></i>
|
||||
<i class="far fa-check-circle text-primary" v-if="!ingredient.checked"></i>
|
||||
</td>
|
||||
<td class="text-nowrap" @click="done">
|
||||
<span v-if="ingredient.amount !== 0" v-html="calculateAmount(ingredient.amount)"></span>
|
||||
</td>
|
||||
<td @click="done">
|
||||
<span v-if="ingredient.unit !== null && !ingredient.no_amount">{{ ingredient.unit.name }}</span>
|
||||
</td>
|
||||
<td @click="done">
|
||||
<template v-if="ingredient.food !== null">
|
||||
<!-- <i
|
||||
v-if="show_shopping && !add_shopping_mode"
|
||||
class="far fa-edit fa-sm px-1"
|
||||
@click="editFood()"
|
||||
></i> -->
|
||||
<a :href="resolveDjangoUrl('view_recipe', ingredient.food.recipe.id)" v-if="ingredient.food.recipe !== null" target="_blank" rel="noopener noreferrer">{{
|
||||
ingredient.food.name
|
||||
}}</a>
|
||||
<span v-if="ingredient.food.recipe === null">{{ ingredient.food.name }}</span>
|
||||
</template>
|
||||
</td>
|
||||
<td v-if="detailed && !show_shopping">
|
||||
<div v-if="ingredient.note">
|
||||
<span v-b-popover.hover="ingredient.note" class="d-print-none touchable">
|
||||
<i class="far fa-comment"></i>
|
||||
</span>
|
||||
<!-- v-if="ingredient.note.length > 15" -->
|
||||
<!-- <span v-else>-->
|
||||
<!-- {{ ingredient.note }}-->
|
||||
<!-- </span>-->
|
||||
|
||||
<div class="d-none d-print-block"><i class="far fa-comment-alt d-print-none"></i> {{ ingredient.note }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td v-else-if="show_shopping" class="text-right text-nowrap">
|
||||
<!-- in shopping mode and ingredient is not ignored -->
|
||||
<div v-if="!ingredient.food.ignore_shopping">
|
||||
<b-button
|
||||
class="btn text-decoration-none fas fa-shopping-cart px-2 user-select-none"
|
||||
variant="link"
|
||||
v-b-popover.hover.click.blur.html.top="{ title: ShoppingPopover, variant: 'outline-dark' }"
|
||||
:class="{
|
||||
'text-success': shopping_status === true,
|
||||
'text-muted': shopping_status === false,
|
||||
'text-warning': shopping_status === null,
|
||||
}"
|
||||
/>
|
||||
<span class="px-2">
|
||||
<input type="checkbox" class="align-middle" v-model="shop" @change="changeShopping" />
|
||||
</span>
|
||||
<on-hand-badge :item="ingredient.food" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- or in shopping mode and food is ignored: Shopping Badge bypasses linking ingredient to Recipe which would get ignored -->
|
||||
<shopping-badge :item="ingredient.food" :override_ignore="true" class="px-1" />
|
||||
<span class="px-2">
|
||||
<input type="checkbox" class="align-middle" disabled v-b-popover.hover.click.blur :title="$t('IgnoredFood', { food: ingredient.food.name })" />
|
||||
</span>
|
||||
<on-hand-badge :item="ingredient.food" />
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import {calculateAmount, ResolveUrlMixin} from "@/utils/utils";
|
||||
import { calculateAmount, ResolveUrlMixin, ApiMixin } from "@/utils/utils"
|
||||
import OnHandBadge from "@/components/Badges/OnHand"
|
||||
import ShoppingBadge from "@/components/Badges/Shopping"
|
||||
|
||||
export default {
|
||||
name: 'IngredientComponent',
|
||||
props: {
|
||||
ingredient: Object,
|
||||
ingredient_factor: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
name: "Ingredient",
|
||||
components: { OnHandBadge, ShoppingBadge },
|
||||
props: {
|
||||
ingredient: Object,
|
||||
ingredient_factor: { type: Number, default: 1 },
|
||||
detailed: { type: Boolean, default: true },
|
||||
recipe_list: { type: Number }, // ShoppingListRecipe ID, to filter ShoppingStatus
|
||||
show_shopping: { type: Boolean, default: false },
|
||||
add_shopping_mode: { type: Boolean, default: false },
|
||||
shopping_list: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
}, // list of unchecked ingredients in shopping list
|
||||
},
|
||||
mixins: [ResolveUrlMixin, ApiMixin],
|
||||
data() {
|
||||
return {
|
||||
checked: false,
|
||||
shopping_status: null,
|
||||
shopping_items: [],
|
||||
shop: false,
|
||||
dirty: undefined,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
ShoppingListAndFilter: {
|
||||
immediate: true,
|
||||
handler(newVal, oldVal) {
|
||||
let filtered_list = this.shopping_list
|
||||
// if a recipe list is provided, filter the shopping list
|
||||
if (this.recipe_list) {
|
||||
filtered_list = filtered_list.filter((x) => x.list_recipe == this.recipe_list)
|
||||
}
|
||||
// how many ShoppingListRecipes are there for this recipe?
|
||||
let count_shopping_recipes = [...new Set(filtered_list.map((x) => x.list_recipe))].length
|
||||
let count_shopping_ingredient = filtered_list.filter((x) => x.ingredient == this.ingredient.id).length
|
||||
|
||||
if (count_shopping_recipes > 1) {
|
||||
this.shop = false // don't check any boxes until user selects a shopping list to edit
|
||||
if (count_shopping_ingredient >= 1) {
|
||||
this.shopping_status = true
|
||||
} else if (this.ingredient.food.shopping) {
|
||||
this.shopping_status = null // food is in the shopping list, just not for this ingredient/recipe
|
||||
} else {
|
||||
this.shopping_status = false // food is not in any shopping list
|
||||
}
|
||||
} else {
|
||||
// mark checked if the food is in the shopping list for this ingredient/recipe
|
||||
if (count_shopping_ingredient >= 1) {
|
||||
// ingredient is in this shopping list
|
||||
this.shop = true
|
||||
this.shopping_status = true
|
||||
} else if (count_shopping_ingredient == 0 && this.ingredient.food.shopping) {
|
||||
// food is in the shopping list, just not for this ingredient/recipe
|
||||
this.shop = false
|
||||
this.shopping_status = null
|
||||
} else {
|
||||
// the food is not in any shopping list
|
||||
this.shop = false
|
||||
this.shopping_status = false
|
||||
}
|
||||
}
|
||||
// if we are in add shopping mode start with all checks marked
|
||||
if (this.add_shopping_mode) {
|
||||
this.shop = !this.ingredient.food.on_hand && !this.ingredient.food.ignore_shopping && !this.ingredient.food.recipe
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {},
|
||||
computed: {
|
||||
ShoppingListAndFilter() {
|
||||
// hack to watch the shopping list and the recipe list at the same time
|
||||
return this.shopping_list.map((x) => x.id).join(this.recipe_list)
|
||||
},
|
||||
ShoppingPopover() {
|
||||
if (this.shopping_status == false) {
|
||||
return this.$t("NotInShopping", { food: this.ingredient.food.name })
|
||||
} else {
|
||||
let list = this.shopping_list.filter((x) => x.food.id == this.ingredient.food.id)
|
||||
let category = this.$t("Category") + ": " + this.ingredient?.food?.supermarket_category?.name ?? this.$t("Undefined")
|
||||
let popover = []
|
||||
|
||||
list.forEach((x) => {
|
||||
popover.push(
|
||||
[
|
||||
"<tr style='border-bottom: 1px solid #ccc'>",
|
||||
"<td style='padding: 3px;'><em>",
|
||||
x?.recipe_mealplan?.name ?? "",
|
||||
"</em></td>",
|
||||
"<td style='padding: 3px;'>",
|
||||
x?.amount ?? "",
|
||||
"</td>",
|
||||
"<td style='padding: 3px;'>",
|
||||
x?.unit?.name ?? "" + "</td>",
|
||||
"<td style='padding: 3px;'>",
|
||||
x?.food?.name ?? "",
|
||||
"</td></tr>",
|
||||
].join("")
|
||||
)
|
||||
})
|
||||
return "<table class='table-small'><th colspan='4'>" + category + "</th>" + popover.join("") + "</table>"
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
calculateAmount: function(x) {
|
||||
return calculateAmount(x, this.ingredient_factor)
|
||||
},
|
||||
// sends parent recipe ingredient to notify complete has been toggled
|
||||
done: function() {
|
||||
this.$emit("checked-state-changed", this.ingredient)
|
||||
},
|
||||
// sends true/false to parent to save all ingredient shopping updates as a batch
|
||||
changeShopping: function() {
|
||||
this.$emit("add-to-shopping", { item: this.ingredient, add: this.shop })
|
||||
},
|
||||
editFood: function() {
|
||||
console.log("edit the food")
|
||||
},
|
||||
},
|
||||
detailed: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
mixins: [
|
||||
ResolveUrlMixin
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
checked: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
calculateAmount: function (x) {
|
||||
return calculateAmount(x, this.ingredient_factor)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* increase size of hover/touchable space without changing spacing */
|
||||
.touchable {
|
||||
padding-right: 2em;
|
||||
padding-left: 2em;
|
||||
margin-right: -2em;
|
||||
margin-left: -2em;
|
||||
padding-right: 2em;
|
||||
padding-left: 2em;
|
||||
margin-right: -2em;
|
||||
margin-left: -2em;
|
||||
}
|
||||
</style>
|
||||
|
||||
187
vue/src/components/IngredientsCard.vue
Normal file
187
vue/src/components/IngredientsCard.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div :class="{ 'card border-primary no-border': header }">
|
||||
<div :class="{ 'card-body': header }">
|
||||
<div class="row" v-if="header">
|
||||
<div class="col col-md-8">
|
||||
<h4 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t("Ingredients") }}</h4>
|
||||
</div>
|
||||
<div class="col col-md-4 text-right" v-if="header">
|
||||
<h4>
|
||||
<i v-if="show_shopping && ShoppingRecipes.length > 0" class="fas fa-trash text-danger px-2" @click="saveShopping(true)"></i>
|
||||
<i v-if="show_shopping" class="fas fa-save text-success px-2" @click="saveShopping()"></i>
|
||||
<i class="fas fa-shopping-cart px-2" @click="getShopping()"></i>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row text-right" v-if="ShoppingRecipes.length > 1">
|
||||
<div class="col col-md-6 offset-md-6 text-right">
|
||||
<b-form-select v-model="selected_shoppingrecipe" :options="ShoppingRecipes" size="sm"></b-form-select>
|
||||
</div>
|
||||
</div>
|
||||
<br v-if="header" />
|
||||
<div class="row no-gutter">
|
||||
<div class="col-md-12">
|
||||
<table class="table table-sm">
|
||||
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||
<template v-for="s in steps">
|
||||
<template v-for="i in s.ingredients">
|
||||
<ingredient
|
||||
:ingredient="i"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:key="i.id"
|
||||
:show_shopping="show_shopping"
|
||||
:shopping_list="shopping_list"
|
||||
:add_shopping_mode="add_shopping_mode"
|
||||
:detailed="detailed"
|
||||
:recipe_list="selected_shoppingrecipe"
|
||||
@checked-state-changed="$emit('checked-state-changed', $event)"
|
||||
@add-to-shopping="addShopping($event)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<!-- eslint-enable vue/no-v-for-template-key-on-child -->
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
|
||||
import Ingredient from "@/components/Ingredient"
|
||||
import { ApiMixin, StandardToasts } from "@/utils/utils"
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: "IngredientCard",
|
||||
mixins: [ApiMixin],
|
||||
components: { Ingredient },
|
||||
props: {
|
||||
steps: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
recipe: { type: Number },
|
||||
ingredient_factor: { type: Number, default: 1 },
|
||||
servings: { type: Number, default: 1 },
|
||||
detailed: { type: Boolean, default: true },
|
||||
header: { type: Boolean, default: false },
|
||||
add_shopping_mode: { type: Boolean, default: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
show_shopping: false,
|
||||
shopping_list: [],
|
||||
update_shopping: [],
|
||||
selected_shoppingrecipe: undefined,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
ShoppingRecipes() {
|
||||
// returns open shopping lists associated with this recipe
|
||||
let recipe_in_list = this.shopping_list
|
||||
.map((x) => {
|
||||
return { value: x?.list_recipe, text: x?.recipe_mealplan?.name, recipe: x?.recipe_mealplan?.recipe ?? 0, servings: x?.recipe_mealplan?.servings }
|
||||
})
|
||||
.filter((x) => x?.recipe == this.recipe)
|
||||
return [...new Map(recipe_in_list.map((x) => [x["value"], x])).values()] // filter to unique lists
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
ShoppingRecipes: function(newVal, oldVal) {
|
||||
if (newVal.length === 0 || this.add_shopping_mode) {
|
||||
this.selected_shoppingrecipe = undefined
|
||||
} else if (newVal.length === 1) {
|
||||
this.selected_shoppingrecipe = newVal[0].value
|
||||
}
|
||||
},
|
||||
selected_shoppingrecipe: function(newVal, oldVal) {
|
||||
this.update_shopping = this.shopping_list.filter((x) => x.list_recipe === newVal).map((x) => x.ingredient)
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.add_shopping_mode) {
|
||||
this.show_shopping = true
|
||||
this.getShopping(false)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getShopping: function(toggle_shopping = true) {
|
||||
if (toggle_shopping) {
|
||||
this.show_shopping = !this.show_shopping
|
||||
}
|
||||
|
||||
if (this.show_shopping) {
|
||||
let ingredient_list = this.steps
|
||||
.map((x) => x.ingredients)
|
||||
.flat()
|
||||
.map((x) => x.food.id)
|
||||
|
||||
let params = {
|
||||
id: ingredient_list,
|
||||
checked: "false",
|
||||
}
|
||||
this.genericAPI(this.Models.SHOPPING_LIST, this.Actions.LIST, params).then((result) => {
|
||||
this.shopping_list = result.data
|
||||
})
|
||||
}
|
||||
},
|
||||
saveShopping: function(del_shopping = false) {
|
||||
let servings = this.servings
|
||||
if (del_shopping) {
|
||||
servings = 0
|
||||
}
|
||||
let params = {
|
||||
id: this.recipe,
|
||||
list_recipe: this.selected_shoppingrecipe,
|
||||
ingredients: this.update_shopping,
|
||||
servings: servings,
|
||||
}
|
||||
this.genericAPI(this.Models.RECIPE, this.Actions.SHOPPING, params)
|
||||
.then(() => {
|
||||
if (del_shopping) {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
|
||||
} else if (this.selected_shoppingrecipe) {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
|
||||
} else {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
if (!this.add_shopping_mode) {
|
||||
return this.getShopping(false)
|
||||
} else {
|
||||
this.$emit("shopping-added")
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (del_shopping) {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
|
||||
} else if (this.selected_shoppingrecipe) {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
} else {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||
}
|
||||
this.$emit("shopping-failed")
|
||||
})
|
||||
},
|
||||
addShopping: function(e) {
|
||||
// ALERT: this will all break if ingredients are re-used between recipes
|
||||
if (e.add) {
|
||||
this.update_shopping.push(e.item.id)
|
||||
} else {
|
||||
this.update_shopping = this.update_shopping.filter((x) => x !== e.item.id)
|
||||
}
|
||||
if (this.add_shopping_mode) {
|
||||
this.$emit("add-to-shopping", e)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -1,216 +0,0 @@
|
||||
<template>
|
||||
<b-modal :id="modal_id" size="lg" :title="modal_title" hide-footer aria-label="" @show="showModal">
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="row">
|
||||
<div class="col-6 col-lg-9">
|
||||
<b-input-group>
|
||||
<b-form-input id="TitleInput" v-model="entryEditing.title"
|
||||
:placeholder="entryEditing.title_placeholder"
|
||||
@change="missing_recipe = false"></b-form-input>
|
||||
<b-input-group-append class="d-none d-lg-block">
|
||||
<b-button variant="primary" @click="entryEditing.title = ''"><i class="fa fa-eraser"></i></b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
<span class="text-danger" v-if="missing_recipe">{{ $t('Title_or_Recipe_Required') }}</span>
|
||||
<small tabindex="-1" class="form-text text-muted" v-if="!missing_recipe">{{ $t("Title") }}</small>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<input type="date" id="DateInput" class="form-control" v-model="entryEditing.date">
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Date") }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-12 col-lg-6 col-xl-6">
|
||||
<b-form-group>
|
||||
<generic-multiselect
|
||||
@change="selectRecipe"
|
||||
:initial_selection="entryEditing_initial_recipe"
|
||||
:label="'name'"
|
||||
:model="Models.RECIPE"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
v-bind:placeholder="$t('Recipe')" :limit="10"
|
||||
:multiple="false"></generic-multiselect>
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Recipe") }}</small>
|
||||
</b-form-group>
|
||||
<b-form-group class="mt-3">
|
||||
<generic-multiselect required
|
||||
@change="selectMealType"
|
||||
:label="'name'"
|
||||
:model="Models.MEAL_TYPE"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
v-bind:placeholder="$t('Meal_Type')" :limit="10"
|
||||
:multiple="false"
|
||||
:initial_selection="entryEditing_initial_meal_type"
|
||||
:allow_create="true"
|
||||
:create_placeholder="$t('Create_New_Meal_Type')"
|
||||
@new="createMealType"
|
||||
></generic-multiselect>
|
||||
<span class="text-danger" v-if="missing_meal_type">{{ $t('Meal_Type_Required') }}</span>
|
||||
<small tabindex="-1" class="form-text text-muted" v-if="!missing_meal_type">{{ $t("Meal_Type") }}</small>
|
||||
</b-form-group>
|
||||
<b-form-group
|
||||
label-for="NoteInput"
|
||||
:description="$t('Note')" class="mt-3">
|
||||
<textarea class="form-control" id="NoteInput" v-model="entryEditing.note"
|
||||
:placeholder="$t('Note')"></textarea>
|
||||
</b-form-group>
|
||||
<b-input-group>
|
||||
<b-form-input id="ServingsInput" v-model="entryEditing.servings"
|
||||
:placeholder="$t('Servings')"></b-form-input>
|
||||
</b-input-group>
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Servings") }}</small>
|
||||
<b-form-group class="mt-3">
|
||||
<generic-multiselect required
|
||||
@change="entryEditing.shared = $event.val" parent_variable="entryEditing.shared"
|
||||
:label="'username'"
|
||||
:model="Models.USER_NAME"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
v-bind:placeholder="$t('Share')" :limit="10"
|
||||
:multiple="true"
|
||||
:initial_selection="entryEditing.shared"
|
||||
></generic-multiselect>
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Share") }}</small>
|
||||
</b-form-group>
|
||||
</div>
|
||||
<div class="col-lg-6 d-none d-lg-block d-xl-block">
|
||||
<recipe-card :recipe="entryEditing.recipe" v-if="entryEditing.recipe != null"></recipe-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3 mb-3">
|
||||
<div class="col-12">
|
||||
<b-button variant="danger" @click="deleteEntry" v-if="allow_delete">{{ $t('Delete') }}
|
||||
</b-button>
|
||||
<b-button class="float-right" variant="primary" @click="editEntry">{{ $t('Save') }}</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</b-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue";
|
||||
import {BootstrapVue} from "bootstrap-vue";
|
||||
import GenericMultiselect from "./GenericMultiselect";
|
||||
import {ApiMixin} from "../utils/utils";
|
||||
|
||||
const {ApiApiFactory} = require("@/utils/openapi/api");
|
||||
|
||||
const {StandardToasts} = require("@/utils/utils");
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: "MealPlanEditModal",
|
||||
props: {
|
||||
entry: Object,
|
||||
entryEditing_initial_recipe: Array,
|
||||
entryEditing_initial_meal_type: Array,
|
||||
modal_title: String,
|
||||
modal_id: {
|
||||
type: String,
|
||||
default: "edit-modal"
|
||||
},
|
||||
allow_delete: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
mixins: [ApiMixin],
|
||||
components: {
|
||||
GenericMultiselect,
|
||||
RecipeCard: () => import('@/components/RecipeCard.vue')
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
entryEditing: {},
|
||||
missing_recipe: false,
|
||||
missing_meal_type: false,
|
||||
default_plan_share: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
entry: {
|
||||
handler() {
|
||||
this.entryEditing = Object.assign({}, this.entry)
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showModal() {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.listUserPreferences().then(result => {
|
||||
if (this.entry.id === -1) {
|
||||
this.entryEditing.shared = result.data[0].plan_share
|
||||
}
|
||||
})
|
||||
},
|
||||
editEntry() {
|
||||
this.missing_meal_type = false
|
||||
this.missing_recipe = false
|
||||
let cancel = false
|
||||
if (this.entryEditing.meal_type == null) {
|
||||
this.missing_meal_type = true
|
||||
cancel = true
|
||||
}
|
||||
if (this.entryEditing.recipe == null && this.entryEditing.title === '') {
|
||||
this.missing_recipe = true
|
||||
cancel = true
|
||||
}
|
||||
if (!cancel) {
|
||||
this.$bvModal.hide(`edit-modal`);
|
||||
this.$emit('save-entry', this.entryEditing)
|
||||
}
|
||||
},
|
||||
deleteEntry() {
|
||||
this.$bvModal.hide(`edit-modal`);
|
||||
this.$emit('delete-entry', this.entryEditing)
|
||||
},
|
||||
selectMealType(event) {
|
||||
this.missing_meal_type = false
|
||||
if (event.val != null) {
|
||||
this.entryEditing.meal_type = event.val;
|
||||
} else {
|
||||
this.entryEditing.meal_type = null;
|
||||
}
|
||||
},
|
||||
selectShared(event) {
|
||||
if (event.val != null) {
|
||||
this.entryEditing.shared = event.val;
|
||||
} else {
|
||||
this.entryEditing.meal_type = null;
|
||||
}
|
||||
},
|
||||
createMealType(event) {
|
||||
if (event != "") {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.createMealType({name: event}).then(e => {
|
||||
this.$emit('reload-meal-types')
|
||||
}).catch(error => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
})
|
||||
}
|
||||
},
|
||||
selectRecipe(event) {
|
||||
this.missing_recipe = false
|
||||
if (event.val != null) {
|
||||
this.entryEditing.recipe = event.val;
|
||||
this.entryEditing.title_placeholder = this.entryEditing.recipe.name
|
||||
this.entryEditing.servings = this.entryEditing.recipe.servings
|
||||
} else {
|
||||
this.entryEditing.recipe = null;
|
||||
this.entryEditing.title_placeholder = ""
|
||||
this.entryEditing.servings = 1
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -28,7 +28,7 @@
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import { getForm } from "@/utils/utils"
|
||||
import { getForm, formFunctions } from "@/utils/utils"
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
@@ -84,6 +84,10 @@ export default {
|
||||
show: function () {
|
||||
if (this.show) {
|
||||
this.form = getForm(this.model, this.action, this.item1, this.item2)
|
||||
// TODO: I don't know how to generalize this, but Food needs default values to drive inheritance
|
||||
if (this.form?.form_function) {
|
||||
this.form = formFunctions[this.form.form_function](this.form)
|
||||
}
|
||||
this.dirty = true
|
||||
this.$bvModal.show("modal_" + this.id)
|
||||
} else {
|
||||
|
||||
219
vue/src/components/Modals/MealPlanEditModal.vue
Normal file
219
vue/src/components/Modals/MealPlanEditModal.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<b-modal :id="modal_id" size="lg" :title="modal_title" hide-footer aria-label="">
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="row">
|
||||
<div class="col-6 col-lg-9">
|
||||
<b-input-group>
|
||||
<b-form-input
|
||||
id="TitleInput"
|
||||
v-model="entryEditing.title"
|
||||
:placeholder="entryEditing.title_placeholder"
|
||||
@change="missing_recipe = false"
|
||||
></b-form-input>
|
||||
<b-input-group-append class="d-none d-lg-block">
|
||||
<b-button variant="primary" @click="entryEditing.title = ''"
|
||||
><i class="fa fa-eraser"></i
|
||||
></b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
<span class="text-danger" v-if="missing_recipe">{{ $t("Title_or_Recipe_Required") }}</span>
|
||||
<small tabindex="-1" class="form-text text-muted" v-if="!missing_recipe">{{
|
||||
$t("Title")
|
||||
}}</small>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<input type="date" id="DateInput" class="form-control" v-model="entryEditing.date" />
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Date") }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-12 col-lg-6 col-xl-6">
|
||||
<b-form-group>
|
||||
<generic-multiselect
|
||||
@change="selectRecipe"
|
||||
:initial_selection="entryEditing_initial_recipe"
|
||||
:label="'name'"
|
||||
:model="Models.RECIPE"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
v-bind:placeholder="$t('Recipe')"
|
||||
:limit="10"
|
||||
:multiple="false"
|
||||
></generic-multiselect>
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Recipe") }}</small>
|
||||
</b-form-group>
|
||||
<b-form-group class="mt-3">
|
||||
<generic-multiselect
|
||||
required
|
||||
@change="selectMealType"
|
||||
:label="'name'"
|
||||
:model="Models.MEAL_TYPE"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
v-bind:placeholder="$t('Meal_Type')"
|
||||
:limit="10"
|
||||
:multiple="false"
|
||||
:initial_selection="entryEditing_initial_meal_type"
|
||||
:allow_create="true"
|
||||
:create_placeholder="$t('Create_New_Meal_Type')"
|
||||
@new="createMealType"
|
||||
></generic-multiselect>
|
||||
<span class="text-danger" v-if="missing_meal_type">{{ $t("Meal_Type_Required") }}</span>
|
||||
<small tabindex="-1" class="form-text text-muted" v-if="!missing_meal_type">{{
|
||||
$t("Meal_Type")
|
||||
}}</small>
|
||||
</b-form-group>
|
||||
<b-form-group label-for="NoteInput" :description="$t('Note')" class="mt-3">
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="NoteInput"
|
||||
v-model="entryEditing.note"
|
||||
:placeholder="$t('Note')"
|
||||
></textarea>
|
||||
</b-form-group>
|
||||
<b-input-group>
|
||||
<b-form-input
|
||||
id="ServingsInput"
|
||||
v-model="entryEditing.servings"
|
||||
:placeholder="$t('Servings')"
|
||||
></b-form-input>
|
||||
</b-input-group>
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("Servings") }}</small>
|
||||
<!-- TODO: hide this checkbox if autoadding menuplans, but allow editing on-hand -->
|
||||
<b-input-group v-if="!autoMealPlan">
|
||||
<b-form-checkbox id="AddToShopping" v-model="entryEditing.addshopping" />
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("AddToShopping") }}</small>
|
||||
</b-input-group>
|
||||
</div>
|
||||
<div class="col-lg-6 d-none d-lg-block d-xl-block">
|
||||
<recipe-card :recipe="entryEditing.recipe" v-if="entryEditing.recipe != null"></recipe-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3 mb-3">
|
||||
<div class="col-12">
|
||||
<b-button variant="danger" @click="deleteEntry" v-if="allow_delete"
|
||||
>{{ $t("Delete") }}
|
||||
</b-button>
|
||||
<b-button class="float-right" variant="primary" @click="editEntry">{{ $t("Save") }}</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</b-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import GenericMultiselect from "@/components/GenericMultiselect"
|
||||
import { ApiMixin, getUserPreference } from "@/utils/utils"
|
||||
|
||||
const { ApiApiFactory } = require("@/utils/openapi/api")
|
||||
|
||||
const { StandardToasts } = require("@/utils/utils")
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: "MealPlanEditModal",
|
||||
props: {
|
||||
entry: Object,
|
||||
entryEditing_initial_recipe: Array,
|
||||
entryEditing_initial_meal_type: Array,
|
||||
modal_title: String,
|
||||
modal_id: {
|
||||
type: String,
|
||||
default: "edit-modal",
|
||||
},
|
||||
allow_delete: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
mixins: [ApiMixin],
|
||||
components: {
|
||||
GenericMultiselect,
|
||||
RecipeCard: () => import("@/components/RecipeCard.vue"),
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
entryEditing: {},
|
||||
missing_recipe: false,
|
||||
missing_meal_type: false,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
entry: {
|
||||
handler() {
|
||||
this.entryEditing = Object.assign({}, this.entry)
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
mounted: function() {},
|
||||
computed: {
|
||||
autoMealPlan: function() {
|
||||
return getUserPreference("mealplan_autoadd_shopping")
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
editEntry() {
|
||||
this.missing_meal_type = false
|
||||
this.missing_recipe = false
|
||||
let cancel = false
|
||||
if (this.entryEditing.meal_type == null) {
|
||||
this.missing_meal_type = true
|
||||
cancel = true
|
||||
}
|
||||
if (this.entryEditing.recipe == null && this.entryEditing.title === "") {
|
||||
this.missing_recipe = true
|
||||
cancel = true
|
||||
}
|
||||
if (!cancel) {
|
||||
this.$bvModal.hide(`edit-modal`)
|
||||
this.$emit("save-entry", this.entryEditing)
|
||||
}
|
||||
},
|
||||
deleteEntry() {
|
||||
this.$bvModal.hide(`edit-modal`)
|
||||
this.$emit("delete-entry", this.entryEditing)
|
||||
},
|
||||
selectMealType(event) {
|
||||
this.missing_meal_type = false
|
||||
if (event.val != null) {
|
||||
this.entryEditing.meal_type = event.val
|
||||
} else {
|
||||
this.entryEditing.meal_type = null
|
||||
}
|
||||
},
|
||||
createMealType(event) {
|
||||
if (event != "") {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient
|
||||
.createMealType({ name: event })
|
||||
.then((e) => {
|
||||
this.$emit("reload-meal-types")
|
||||
})
|
||||
.catch((error) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
|
||||
})
|
||||
}
|
||||
},
|
||||
selectRecipe(event) {
|
||||
console.log(event, this.entryEditing)
|
||||
this.missing_recipe = false
|
||||
if (event.val != null) {
|
||||
this.entryEditing.recipe = event.val
|
||||
this.entryEditing.title_placeholder = this.entryEditing.recipe.name
|
||||
this.entryEditing.servings = this.entryEditing.recipe.servings
|
||||
} else {
|
||||
this.entryEditing.recipe = null
|
||||
this.entryEditing.title_placeholder = ""
|
||||
this.entryEditing.servings = 1
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
158
vue/src/components/Modals/ShoppingModal.vue
Normal file
158
vue/src/components/Modals/ShoppingModal.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-modal :id="`shopping_${this.modal_id}`" hide-footer @show="loadRecipe">
|
||||
<template v-slot:modal-title><h4>{{$t('Add_Servings_to_Shopping',{'servings': servings})}}</h4></template>
|
||||
<loading-spinner v-if="loading"></loading-spinner>
|
||||
<div class="accordion" role="tablist" v-if="!loading">
|
||||
<b-card no-body class="mb-1">
|
||||
<b-card-header header-tag="header" class="p-1" role="tab">
|
||||
<b-button block v-b-toggle.accordion-0 class="text-left" variant="outline-info">{{recipe.name}}</b-button>
|
||||
</b-card-header>
|
||||
<b-collapse id="accordion-0" visible accordion="my-accordion" role="tabpanel">
|
||||
<ingredients-card
|
||||
:steps="steps"
|
||||
:recipe="recipe.id"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:servings="servings"
|
||||
:show_shopping="true"
|
||||
:add_shopping_mode="true"
|
||||
:header="false"
|
||||
@add-to-shopping="addShopping($event)"
|
||||
/>
|
||||
</b-collapse>
|
||||
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||
<template v-for="r in related_recipes">
|
||||
<b-card no-body class="mb-1" :key="r.recipe.id">
|
||||
<b-card-header header-tag="header" class="p-1" role="tab">
|
||||
<b-button btn-sm block v-b-toggle="'accordion-' +r.recipe.id" class="text-left" variant="outline-primary">{{r.recipe.name}}</b-button>
|
||||
</b-card-header>
|
||||
<b-collapse :id="'accordion-'+r.recipe.id" accordion="my-accordion" role="tabpanel">
|
||||
<ingredients-card
|
||||
:steps="r.steps"
|
||||
:recipe="r.recipe.id"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
:servings="servings"
|
||||
:show_shopping="true"
|
||||
:add_shopping_mode="true"
|
||||
:header="false"
|
||||
@add-to-shopping="addShopping($event)"
|
||||
/>
|
||||
</b-collapse>
|
||||
</b-card>
|
||||
</template>
|
||||
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||
</b-card>
|
||||
|
||||
</div>
|
||||
<div class="row mt-3 mb-3">
|
||||
<div class="col-12 text-right">
|
||||
<b-button class="mx-2" variant="secondary" @click="$bvModal.hide(`shopping_${modal_id}`)">{{ $t('Cancel') }}
|
||||
</b-button>
|
||||
<b-button class="mx-2" variant="success" @click="saveShopping">{{ $t('Save') }}
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from 'vue'
|
||||
import {BootstrapVue} from 'bootstrap-vue'
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
const {ApiApiFactory} = require("@/utils/openapi/api");
|
||||
import {StandardToasts} from "@/utils/utils";
|
||||
import IngredientsCard from "@/components/IngredientsCard";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||
|
||||
|
||||
export default {
|
||||
name: 'ShoppingModal',
|
||||
components: {IngredientsCard, LoadingSpinner},
|
||||
mixins: [],
|
||||
props: {
|
||||
recipe: {required: true, type: Object},
|
||||
servings: {type: Number},
|
||||
modal_id: {required: true, type: Number},
|
||||
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
steps: [],
|
||||
recipe_servings: 0,
|
||||
add_shopping: [],
|
||||
related_recipes: []
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
},
|
||||
computed: {
|
||||
ingredient_factor: function () {
|
||||
return this.servings / this.recipe.servings || this.recipe_servings
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
|
||||
},
|
||||
methods: {
|
||||
loadRecipe: function() {
|
||||
this.add_shopping = []
|
||||
this.related_recipes = []
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient.retrieveRecipe(this.recipe.id).then((result) => {
|
||||
this.steps = result.data.steps
|
||||
// ALERT: this will all break if ingredients are re-used between recipes
|
||||
// ALERT: this also doesn't quite work right if the same recipe appears multiple time in the related recipes
|
||||
this.add_shopping = [...this.add_shopping, ...this.steps.map(x => x.ingredients).flat().map(x => x.id)]
|
||||
this.recipe_servings = result.data?.servings
|
||||
this.loading = false
|
||||
})
|
||||
|
||||
// get a list of related recipes
|
||||
apiClient.relatedRecipe(this.recipe.id).then((result) => {
|
||||
return result.data
|
||||
}).then((related_recipes) => {
|
||||
let promises = []
|
||||
related_recipes.forEach(x => {
|
||||
promises.push(apiClient.listSteps(x.id).then((recipe_steps) => {
|
||||
this.related_recipes.push({
|
||||
'recipe': x,
|
||||
'steps': recipe_steps.data.results.filter(x => x.ingredients.length > 0)
|
||||
})
|
||||
}))
|
||||
})
|
||||
return Promise.all(promises)
|
||||
}).then(() => {
|
||||
this.add_shopping = [
|
||||
...this.add_shopping,
|
||||
...this.related_recipes.map(x => x.steps).flat().map(x => x.ingredients).flat().map(x => x.id)
|
||||
]
|
||||
})
|
||||
},
|
||||
addShopping: function(e) {
|
||||
if (e.add) {
|
||||
this.add_shopping.push(e.item.id)
|
||||
} else {
|
||||
this.add_shopping = this.add_shopping.filter(x => x !== e.item.id)
|
||||
}
|
||||
},
|
||||
saveShopping: function() {
|
||||
// another choice would be to create ShoppingListRecipes for each recipe - this bundles all related recipe under the parent recipe
|
||||
let shopping_recipe = {
|
||||
'id': this.recipe.id,
|
||||
'ingredients': this.add_shopping,
|
||||
'servings': this.servings
|
||||
}
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient.shoppingRecipe(this.recipe.id, shopping_recipe).then((result) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
|
||||
}).catch((err) => {
|
||||
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
|
||||
})
|
||||
this.$bvModal.hide(`shopping_${this.modal_id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,158 +1,137 @@
|
||||
<template>
|
||||
|
||||
|
||||
<b-card no-body v-hover>
|
||||
<a :href="clickUrl()">
|
||||
<b-card-img-lazy style="height: 15vh; object-fit: cover" class="" :src=recipe_image
|
||||
v-bind:alt="$t('Recipe_Image')"
|
||||
top></b-card-img-lazy>
|
||||
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right pt-2 pr-1">
|
||||
<a>
|
||||
<recipe-context-menu :recipe="recipe" class="float-right" v-if="recipe !== null"></recipe-context-menu>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-img-overlay w-50 d-flex flex-column justify-content-left float-left text-left pt-2"
|
||||
v-if="recipe.working_time !== 0 || recipe.waiting_time !== 0">
|
||||
<b-badge pill variant="light" class="mt-1 font-weight-normal" v-if="recipe.working_time !== 0"><i class="fa fa-clock"></i>
|
||||
{{ recipe.working_time }} {{ $t('min') }}
|
||||
</b-badge>
|
||||
<b-badge pill variant="secondary" class="mt-1 font-weight-normal" v-if="recipe.waiting_time !== 0"><i class="fa fa-pause"></i>
|
||||
{{ recipe.waiting_time }} {{ $t('min') }}
|
||||
</b-badge>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
|
||||
<b-card-body class="p-4">
|
||||
<h6><a :href="clickUrl()">
|
||||
<template v-if="recipe !== null">{{ recipe.name }}</template>
|
||||
<template v-else>{{ meal_plan.title }}</template>
|
||||
</a></h6>
|
||||
|
||||
<b-card-text style="text-overflow: ellipsis;">
|
||||
<template v-if="recipe !== null">
|
||||
<recipe-rating :recipe="recipe"></recipe-rating>
|
||||
<template v-if="recipe.description !== null">
|
||||
<span v-if="recipe.description.length > text_length">
|
||||
{{ recipe.description.substr(0, text_length) + "\u2026" }}
|
||||
</span>
|
||||
<span v-if="recipe.description.length <= text_length">
|
||||
{{ recipe.description }}
|
||||
</span>
|
||||
</template>
|
||||
<p class="mt-1">
|
||||
<last-cooked :recipe="recipe"></last-cooked>
|
||||
<keywords-component :recipe="recipe" style="margin-top: 4px"></keywords-component>
|
||||
</p>
|
||||
<transition name="fade" mode="in-out">
|
||||
<div class="row mt-3" v-if="detailed">
|
||||
<div class="col-md-12">
|
||||
<h6 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t('Ingredients') }}</h6>
|
||||
<table class="table table-sm text-wrap">
|
||||
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||
<template v-for="s in recipe.steps">
|
||||
<template v-for="i in s.ingredients">
|
||||
<Ingredient-component :detailed="false" :ingredient="i" :ingredient_factor="1" :key="i.id"></Ingredient-component>
|
||||
</template>
|
||||
</template>
|
||||
<!-- eslint-enable vue/no-v-for-template-key-on-child -->
|
||||
</table>
|
||||
</div>
|
||||
<b-card no-body v-hover v-if="recipe">
|
||||
<a :href="clickUrl()">
|
||||
<b-card-img-lazy style="height: 15vh; object-fit: cover" class="" :src="recipe_image" v-bind:alt="$t('Recipe_Image')" top></b-card-img-lazy>
|
||||
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right pt-2 pr-1">
|
||||
<a>
|
||||
<recipe-context-menu :recipe="recipe" class="float-right" v-if="recipe !== null"></recipe-context-menu>
|
||||
</a>
|
||||
</div>
|
||||
</transition>
|
||||
<div class="card-img-overlay w-50 d-flex flex-column justify-content-left float-left text-left pt-2" v-if="recipe.waiting_time !== 0">
|
||||
<b-badge pill variant="light" class="mt-1 font-weight-normal"><i class="fa fa-clock"></i> {{ recipe.working_time }} {{ $t("min") }} </b-badge>
|
||||
<b-badge pill variant="secondary" class="mt-1 font-weight-normal"><i class="fa fa-pause"></i> {{ recipe.waiting_time }} {{ $t("min") }} </b-badge>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<b-badge pill variant="info" v-if="!recipe.internal">{{ $t('External') }}</b-badge>
|
||||
<!-- <b-badge pill variant="success"
|
||||
<b-card-body class="p-4">
|
||||
<h6>
|
||||
<a :href="clickUrl()">
|
||||
<template v-if="recipe !== null">{{ recipe.name }}</template>
|
||||
<template v-else>{{ meal_plan.title }}</template>
|
||||
</a>
|
||||
</h6>
|
||||
|
||||
<b-card-text style="text-overflow: ellipsis;">
|
||||
<template v-if="recipe !== null">
|
||||
<recipe-rating :recipe="recipe"></recipe-rating>
|
||||
<template v-if="recipe.description !== null">
|
||||
<span v-if="recipe.description.length > text_length">
|
||||
{{ recipe.description.substr(0, text_length) + "\u2026" }}
|
||||
</span>
|
||||
<span v-if="recipe.description.length <= text_length">
|
||||
{{ recipe.description }}
|
||||
</span>
|
||||
</template>
|
||||
<p class="mt-1">
|
||||
<last-cooked :recipe="recipe"></last-cooked>
|
||||
<keywords :recipe="recipe" style="margin-top: 4px"></keywords>
|
||||
</p>
|
||||
<transition name="fade" mode="in-out">
|
||||
<div class="row mt-3" v-if="detailed">
|
||||
<div class="col-md-12">
|
||||
<h6 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t("Ingredients") }}</h6>
|
||||
|
||||
<ingredients-card :steps="recipe.steps" :header="false" :detailed="false" />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<b-badge pill variant="info" v-if="!recipe.internal">{{ $t("External") }}</b-badge>
|
||||
<!-- <b-badge pill variant="success"
|
||||
v-if="Date.parse(recipe.created_at) > new Date(Date.now() - (7 * (1000 * 60 * 60 * 24)))">
|
||||
{{ $t('New') }}
|
||||
</b-badge> -->
|
||||
</template>
|
||||
<template v-else>{{ meal_plan.note }}</template>
|
||||
</b-card-text>
|
||||
</b-card-body>
|
||||
|
||||
</template>
|
||||
<template v-else>{{ meal_plan.note }}</template>
|
||||
</b-card-text>
|
||||
</b-card-body>
|
||||
|
||||
|
||||
<b-card-footer v-if="footer_text !== undefined">
|
||||
<i v-bind:class="footer_icon"></i> {{ footer_text }}
|
||||
</b-card-footer>
|
||||
</b-card>
|
||||
|
||||
<b-card-footer v-if="footer_text !== undefined"> <i v-bind:class="footer_icon"></i> {{ footer_text }} </b-card-footer>
|
||||
</b-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import RecipeContextMenu from "@/components/RecipeContextMenu";
|
||||
import {resolveDjangoUrl, ResolveUrlMixin} from "@/utils/utils";
|
||||
import RecipeRating from "@/components/RecipeRating";
|
||||
import moment from "moment/moment";
|
||||
import Vue from "vue";
|
||||
import LastCooked from "@/components/LastCooked";
|
||||
import KeywordsComponent from "@/components/KeywordsComponent";
|
||||
import IngredientComponent from "@/components/IngredientComponent";
|
||||
import RecipeContextMenu from "@/components/ContextMenu/RecipeContextMenu"
|
||||
import Keywords from "@/components/Keywords"
|
||||
import { resolveDjangoUrl, ResolveUrlMixin } from "@/utils/utils"
|
||||
import RecipeRating from "@/components/RecipeRating"
|
||||
import moment from "moment/moment"
|
||||
import Vue from "vue"
|
||||
import LastCooked from "@/components/LastCooked"
|
||||
import IngredientsCard from "@/components/IngredientsCard"
|
||||
|
||||
Vue.prototype.moment = moment
|
||||
|
||||
export default {
|
||||
name: "RecipeCard",
|
||||
mixins: [
|
||||
ResolveUrlMixin,
|
||||
],
|
||||
components: {LastCooked, RecipeRating, KeywordsComponent, RecipeContextMenu, IngredientComponent},
|
||||
props: {
|
||||
recipe: Object,
|
||||
meal_plan: Object,
|
||||
footer_text: String,
|
||||
footer_icon: String
|
||||
},
|
||||
computed: {
|
||||
detailed: function () {
|
||||
return this.recipe.steps !== undefined;
|
||||
name: "RecipeCard",
|
||||
mixins: [ResolveUrlMixin],
|
||||
components: { LastCooked, RecipeRating, Keywords, RecipeContextMenu, IngredientsCard },
|
||||
props: {
|
||||
recipe: Object,
|
||||
meal_plan: Object,
|
||||
footer_text: String,
|
||||
footer_icon: String,
|
||||
},
|
||||
text_length: function () {
|
||||
if (this.detailed) {
|
||||
return 200
|
||||
} else {
|
||||
return 120
|
||||
}
|
||||
computed: {
|
||||
detailed: function() {
|
||||
return this.recipe?.steps !== undefined
|
||||
},
|
||||
text_length: function() {
|
||||
if (this.detailed) {
|
||||
return 200
|
||||
} else {
|
||||
return 120
|
||||
}
|
||||
},
|
||||
recipe_image: function() {
|
||||
if (this.recipe == null || this.recipe.image === null) {
|
||||
return window.IMAGE_PLACEHOLDER
|
||||
} else {
|
||||
return this.recipe.image
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// TODO: convert this to genericAPI
|
||||
clickUrl: function() {
|
||||
if (this.recipe !== null) {
|
||||
return resolveDjangoUrl("view_recipe", this.recipe.id)
|
||||
} else {
|
||||
return resolveDjangoUrl("view_plan_entry", this.meal_plan.id)
|
||||
}
|
||||
},
|
||||
},
|
||||
directives: {
|
||||
hover: {
|
||||
inserted: function(el) {
|
||||
el.addEventListener("mouseenter", () => {
|
||||
el.classList.add("shadow")
|
||||
})
|
||||
el.addEventListener("mouseleave", () => {
|
||||
el.classList.remove("shadow")
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
recipe_image: function () {
|
||||
if (this.recipe == null || this.recipe.image === null) {
|
||||
return window.IMAGE_PLACEHOLDER
|
||||
} else {
|
||||
return this.recipe.image
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// TODO: convert this to genericAPI
|
||||
clickUrl: function () {
|
||||
if (this.recipe !== null) {
|
||||
return resolveDjangoUrl('view_recipe', this.recipe.id)
|
||||
} else {
|
||||
return resolveDjangoUrl('view_plan_entry', this.meal_plan.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
directives: {
|
||||
hover: {
|
||||
inserted: function (el) {
|
||||
el.addEventListener('mouseenter', () => {
|
||||
el.classList.add("shadow")
|
||||
});
|
||||
el.addEventListener('mouseleave', () => {
|
||||
el.classList.remove("shadow")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity .5s;
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
|
||||
opacity: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
269
vue/src/components/ShoppingLineItem.vue
Normal file
269
vue/src/components/ShoppingLineItem.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<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="shopping_line_item">
|
||||
<div class="col-12">
|
||||
<div class="row">
|
||||
<div class="col col-md-1">
|
||||
<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, entries)"
|
||||
>
|
||||
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<input type="checkbox" class="text-right mx-3 mt-2" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<div v-if="Object.entries(formatAmount).length == 1">{{ Object.entries(formatAmount)[0][1] }}   {{ Object.entries(formatAmount)[0][0] }}</div>
|
||||
<div class="small" v-else v-for="(x, i) in Object.entries(formatAmount)" :key="i">{{ x[1] }}   {{ x[0] }}</div>
|
||||
</div>
|
||||
|
||||
<div class="col col-md-6">
|
||||
{{ formatFood }} <span class="small text-muted">{{ formatHint }}</span>
|
||||
</div>
|
||||
<div class="col col-md-1">
|
||||
<b-button size="sm" @click="showDetails = !showDetails" class="mr-2" variant="link">
|
||||
<div class="text-nowrap">{{ showDetails ? "Hide" : "Show" }} Details</div>
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card no-body" v-if="showDetails">
|
||||
<div v-for="(e, z) in entries" :key="z">
|
||||
<div class="row ml-2 small">
|
||||
<div class="col-md-4 overflow-hidden text-nowrap">
|
||||
<button
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
type="button"
|
||||
class="btn btn-link btn-sm m-0 p-0"
|
||||
style="text-overflow: ellipsis;"
|
||||
@click.stop="openRecipeCard($event, e)"
|
||||
@mouseover="openRecipeCard($event, e)"
|
||||
>
|
||||
{{ formatOneRecipe(e) }}
|
||||
</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 class="col-sm-1">{{ formatOneAmount(e) }}</div>
|
||||
<div class="col-sm-2">{{ formatOneUnit(e) }}</div>
|
||||
|
||||
<div class="col-sm-3">{{ formatOneFood(e) }}</div>
|
||||
|
||||
<div class="col-sm-4">
|
||||
<div class="small" v-for="(n, i) in formatOneNote(e)" :key="i">{{ n }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="w-75" />
|
||||
</div>
|
||||
</div>
|
||||
<hr class="m-1" />
|
||||
</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>
|
||||
</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 { ApiMixin } from "@/utils/utils"
|
||||
import RecipeCard from "./RecipeCard.vue"
|
||||
|
||||
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: "ShoppingLineItem",
|
||||
mixins: [ApiMixin],
|
||||
components: { RecipeCard, ContextMenu, ContextMenuItem },
|
||||
props: {
|
||||
entries: {
|
||||
type: Array,
|
||||
},
|
||||
groupby: { type: String },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showDetails: false,
|
||||
recipe: undefined,
|
||||
servings: 1,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
formatAmount: function() {
|
||||
let 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() {
|
||||
return this.formatOneCategory(this.entries[0]) || this.$t("Undefined")
|
||||
},
|
||||
formatChecked: function() {
|
||||
return this.entries.map((x) => x.checked).every((x) => x === true)
|
||||
},
|
||||
formatHint: function() {
|
||||
if (this.groupby == "recipe") {
|
||||
return this.formatCategory
|
||||
} else {
|
||||
return this.formatRecipe
|
||||
}
|
||||
},
|
||||
formatFood: function() {
|
||||
return this.formatOneFood(this.entries[0])
|
||||
},
|
||||
formatUnit: function() {
|
||||
return this.formatOneUnit(this.entries[0])
|
||||
},
|
||||
formatRecipe: function() {
|
||||
if (this.entries?.length == 1) {
|
||||
return this.formatOneMealPlan(this.entries[0]) || ""
|
||||
} else {
|
||||
let mealplan_name = this.entries.filter((x) => x?.recipe_mealplan?.name)
|
||||
return [this.formatOneMealPlan(mealplan_name?.[0]), this.$t("CountMore", { count: this.entries?.length - 1 })].join(" ")
|
||||
}
|
||||
},
|
||||
formatNotes: function() {
|
||||
if (this.entries?.length == 1) {
|
||||
return this.formatOneNote(this.entries[0]) || ""
|
||||
}
|
||||
return ""
|
||||
},
|
||||
},
|
||||
watch: {},
|
||||
mounted() {
|
||||
this.servings = this.entries?.[0]?.recipe_mealplan?.servings ?? 0
|
||||
},
|
||||
methods: {
|
||||
// this.genericAPI inherited from ApiMixin
|
||||
|
||||
formatDate: function(datetime) {
|
||||
if (!datetime) {
|
||||
return
|
||||
}
|
||||
return Intl.DateTimeFormat(window.navigator.language, { dateStyle: "short", timeStyle: "short" }).format(Date.parse(datetime))
|
||||
},
|
||||
formatOneAmount: function(item) {
|
||||
return item?.amount ?? 1
|
||||
},
|
||||
formatOneUnit: function(item) {
|
||||
return item?.unit?.name ?? ""
|
||||
},
|
||||
formatOneCategory: function(item) {
|
||||
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) {
|
||||
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 })
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--style src="vue-multiselect/dist/vue-multiselect.min.css"></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>
|
||||
@@ -38,12 +38,11 @@
|
||||
<div class="col col-md-4"
|
||||
v-if="step.ingredients.length > 0 && (recipe.steps.length > 1 || force_ingredients)">
|
||||
<table class="table table-sm">
|
||||
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
|
||||
<template v-for="i in step.ingredients">
|
||||
<Ingredient-component v-bind:ingredient="i" :ingredient_factor="ingredient_factor" :key="i.id"
|
||||
@checked-state-changed="$emit('checked-state-changed', i)"></Ingredient-component>
|
||||
</template>
|
||||
<!-- eslint-enable vue/no-v-for-template-key-on-child -->
|
||||
<ingredients-card
|
||||
:steps="[step]"
|
||||
:ingredient_factor="ingredient_factor"
|
||||
@checked-state-changed="$emit('checked-state-changed', $event)"
|
||||
/>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col" :class="{ 'col-md-8': recipe.steps.length > 1, 'col-md-12': recipe.steps.length <= 1,}">
|
||||
@@ -161,6 +160,7 @@ import {calculateAmount} from "@/utils/utils";
|
||||
import {GettextMixin} from "@/utils/utils";
|
||||
|
||||
import CompileComponent from "@/components/CompileComponent";
|
||||
import IngredientsCard from "@/components/IngredientsCard";
|
||||
import Vue from "vue";
|
||||
import moment from "moment";
|
||||
import {ResolveUrlMixin} from "@/utils/utils";
|
||||
@@ -174,10 +174,7 @@ export default {
|
||||
GettextMixin,
|
||||
ResolveUrlMixin,
|
||||
],
|
||||
components: {
|
||||
IngredientComponent,
|
||||
CompileComponent,
|
||||
},
|
||||
components: { CompileComponent, IngredientsCard},
|
||||
props: {
|
||||
step: Object,
|
||||
ingredient_factor: Number,
|
||||
|
||||
@@ -173,6 +173,15 @@
|
||||
"Time": "Time",
|
||||
"Text": "Text",
|
||||
"Shopping_list": "Shopping List",
|
||||
"Added_by": "Added By",
|
||||
"Added_on": "Added On",
|
||||
"AddToShopping": "Add to shopping list",
|
||||
"IngredientInShopping": "This ingredient is in your shopping list.",
|
||||
"NotInShopping": "{food} is not in your shopping list.",
|
||||
"OnHand": "Have On Hand",
|
||||
"FoodOnHand": "You have {food} on hand.",
|
||||
"FoodNotOnHand": "You do not have {food} on hand.",
|
||||
"Undefined": "Undefined",
|
||||
"Create_Meal_Plan_Entry": "Create meal plan entry",
|
||||
"Edit_Meal_Plan_Entry": "Edit meal plan entry",
|
||||
"Title": "Title",
|
||||
@@ -194,6 +203,41 @@
|
||||
"Title_or_Recipe_Required": "Title or recipe selection required",
|
||||
"Color": "Color",
|
||||
"New_Meal_Type": "New Meal type",
|
||||
"AddFoodToShopping": "Add {food} to your shopping list",
|
||||
"RemoveFoodFromShopping": "Remove {food} from your shopping list",
|
||||
"DeleteShoppingConfirm": "Are you sure that you want to remove all {food} from the shopping list?",
|
||||
"IgnoredFood": "{food} is set to ignore shopping.",
|
||||
"Add_Servings_to_Shopping": "Add {servings} Servings to Shopping",
|
||||
"Inherit": "Inherit",
|
||||
"IgnoreInherit": "Do Not Inherit Fields",
|
||||
"FoodInherit": "Food Inheritable Fields",
|
||||
"ShowUncategorizedFood": "Show Undefined",
|
||||
"GroupBy": "Group By",
|
||||
"SupermarketCategoriesOnly": "Supermarket Categories Only",
|
||||
"MoveCategory": "Move To: ",
|
||||
"CountMore": "...+{count} more",
|
||||
"IgnoreThis": "Never auto-add {food} to shopping",
|
||||
"DelayFor": "Delay for {hours} hours",
|
||||
"Warning": "Warning",
|
||||
"NoCategory": "No category selected.",
|
||||
"InheritWarning": "{food} is set to inherit, changes may not persist.",
|
||||
"ShowDelayed": "Show Delayed Items",
|
||||
"Completed": "Completed",
|
||||
"OfflineAlert": "You are offline, shopping list may not syncronize.",
|
||||
"shopping_share": "Share Shopping List",
|
||||
"shopping_auto_sync": "Autosync",
|
||||
"mealplan_autoadd_shopping": "Auto Add Meal Plan",
|
||||
"mealplan_autoexclude_onhand": "Exclude On Hand",
|
||||
"mealplan_autoinclude_related": "Add Related Recipes",
|
||||
"default_delay": "Default Delay Hours",
|
||||
"shopping_share_desc": "Users will see all items you add to your shopping list. They must add you to see items on their list.",
|
||||
"shopping_auto_sync_desc": "Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but will use mobile data.",
|
||||
"mealplan_autoadd_shopping_desc": "Automatically add meal plan ingredients to shopping list.",
|
||||
"mealplan_autoexclude_onhand_desc": "When automatically adding a meal plan to the shopping list, exclude ingredients that are on hand.",
|
||||
"mealplan_autoinclude_related_desc": "When automatically adding a meal plan to the shopping list, include all related recipes.",
|
||||
"default_delay_desc": "Default number of hours to delay a shopping list entry.",
|
||||
"filter_to_supermarket": "Filter to Supermarket",
|
||||
"filter_to_supermarket_desc": "Filter shopping list to only include supermarket categories.",
|
||||
"Week_Numbers": "Week numbers",
|
||||
"Show_Week_Numbers": "Show week numbers ?",
|
||||
"Export_As_ICal": "Export current period to iCal format",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from "axios";
|
||||
import {djangoGettext as _, makeToast} from "@/utils/utils";
|
||||
import {resolveDjangoUrl} from "@/utils/utils";
|
||||
import {ApiApiFactory} from "@/utils/openapi/api.ts";
|
||||
|
||||
axios.defaults.xsrfCookieName = 'csrftoken'
|
||||
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
|
||||
@@ -47,4 +48,8 @@ function handleError(error, message) {
|
||||
makeToast('Error', message, 'danger')
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Generic class to use OpenAPIs with parameters and provide generic modals
|
||||
* */
|
||||
16
vue/src/utils/apiv2.js
Normal file
16
vue/src/utils/apiv2.js
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Utility functions to use OpenAPIs generically
|
||||
* */
|
||||
import {ApiApiFactory} from "@/utils/openapi/api.ts";
|
||||
|
||||
import axios from "axios";
|
||||
axios.defaults.xsrfCookieName = 'csrftoken'
|
||||
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
|
||||
|
||||
export class GenericAPI {
|
||||
constructor(model, action) {
|
||||
this.model = model;
|
||||
this.action = action;
|
||||
this.function_name = action + model
|
||||
}
|
||||
}
|
||||
@@ -67,12 +67,15 @@ export class Models {
|
||||
merge: true,
|
||||
badges: {
|
||||
linked_recipe: true,
|
||||
on_hand: true,
|
||||
shopping: true,
|
||||
},
|
||||
tags: [{ field: "supermarket_category", label: "name", color: "info" }],
|
||||
// REQUIRED: unordered array of fields that can be set during create
|
||||
create: {
|
||||
// if not defined partialUpdate will use the same parameters, prepending 'id'
|
||||
params: [["name", "description", "recipe", "ignore_shopping", "supermarket_category"]],
|
||||
params: [["name", "description", "recipe", "ignore_shopping", "supermarket_category", "on_hand", "inherit", "ignore_inherit"]],
|
||||
|
||||
form: {
|
||||
name: {
|
||||
form_field: true,
|
||||
@@ -101,6 +104,12 @@ export class Models {
|
||||
field: "ignore_shopping",
|
||||
label: i18n.t("Ignore_Shopping"),
|
||||
},
|
||||
onhand: {
|
||||
form_field: true,
|
||||
type: "checkbox",
|
||||
field: "on_hand",
|
||||
label: i18n.t("OnHand"),
|
||||
},
|
||||
shopping_category: {
|
||||
form_field: true,
|
||||
type: "lookup",
|
||||
@@ -109,8 +118,30 @@ export class Models {
|
||||
label: i18n.t("Shopping_Category"),
|
||||
allow_create: true,
|
||||
},
|
||||
inherit: {
|
||||
form_field: true,
|
||||
type: "checkbox",
|
||||
field: "inherit",
|
||||
label: i18n.t("Inherit"),
|
||||
},
|
||||
ignore_inherit: {
|
||||
form_field: true,
|
||||
type: "lookup",
|
||||
multiple: true,
|
||||
field: "ignore_inherit",
|
||||
list: "FOOD_INHERIT_FIELDS",
|
||||
label: i18n.t("IgnoreInherit"),
|
||||
},
|
||||
form_function: "FoodCreateDefault",
|
||||
},
|
||||
},
|
||||
shopping: {
|
||||
params: ["id", ["id", "amount", "unit", "_delete"]],
|
||||
},
|
||||
}
|
||||
static FOOD_INHERIT_FIELDS = {
|
||||
name: i18n.t("FoodInherit"),
|
||||
apiName: "FoodInheritField",
|
||||
}
|
||||
|
||||
static KEYWORD = {
|
||||
@@ -180,6 +211,12 @@ export class Models {
|
||||
static SHOPPING_LIST = {
|
||||
name: i18n.t("Shopping_list"),
|
||||
apiName: "ShoppingListEntry",
|
||||
list: {
|
||||
params: ["id", "checked", "supermarket", "options"],
|
||||
},
|
||||
create: {
|
||||
params: [["amount", "unit", "food", "checked"]],
|
||||
},
|
||||
}
|
||||
|
||||
static RECIPE_BOOK = {
|
||||
@@ -370,41 +407,15 @@ export class Models {
|
||||
name: i18n.t("Recipe"),
|
||||
apiName: "Recipe",
|
||||
list: {
|
||||
params: [
|
||||
"query",
|
||||
"keywords",
|
||||
"foods",
|
||||
"units",
|
||||
"rating",
|
||||
"books",
|
||||
"steps",
|
||||
"keywordsOr",
|
||||
"foodsOr",
|
||||
"booksOr",
|
||||
"internal",
|
||||
"random",
|
||||
"_new",
|
||||
"page",
|
||||
"pageSize",
|
||||
"options",
|
||||
],
|
||||
config: {
|
||||
foods: { type: "string" },
|
||||
keywords: { type: "string" },
|
||||
books: { type: "string" },
|
||||
},
|
||||
params: ["query", "keywords", "foods", "units", "rating", "books", "keywordsOr", "foodsOr", "booksOr", "internal", "random", "_new", "page", "pageSize", "options"],
|
||||
// 'config': {
|
||||
// 'foods': {'type': 'string'},
|
||||
// 'keywords': {'type': 'string'},
|
||||
// 'books': {'type': 'string'},
|
||||
// }
|
||||
},
|
||||
}
|
||||
|
||||
static STEP = {
|
||||
name: i18n.t("Step"),
|
||||
apiName: "Step",
|
||||
paginated: true,
|
||||
list: {
|
||||
header_component: {
|
||||
name: "BetaWarning",
|
||||
},
|
||||
params: ["query", "page", "pageSize", "options"],
|
||||
shopping: {
|
||||
params: ["id", ["id", "list_recipe", "ingredients", "servings"]],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -461,6 +472,11 @@ export class Models {
|
||||
},
|
||||
},
|
||||
}
|
||||
static USER = {
|
||||
name: i18n.t("User"),
|
||||
apiName: "User",
|
||||
paginated: false,
|
||||
}
|
||||
}
|
||||
|
||||
export class Actions {
|
||||
@@ -639,4 +655,7 @@ export class Actions {
|
||||
},
|
||||
},
|
||||
}
|
||||
static SHOPPING = {
|
||||
function: "shopping",
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ import Vue from "vue"
|
||||
import { Actions, Models } from "./models"
|
||||
|
||||
export const ToastMixin = {
|
||||
name: "ToastMixin",
|
||||
methods: {
|
||||
makeToast: function (title, message, variant = null) {
|
||||
return makeToast(title, message, variant)
|
||||
@@ -147,12 +148,17 @@ export function resolveDjangoUrl(url, params = null) {
|
||||
/*
|
||||
* other utilities
|
||||
* */
|
||||
|
||||
export function getUserPreference(pref) {
|
||||
if (window.USER_PREF === undefined) {
|
||||
export function getUserPreference(pref = undefined) {
|
||||
let user_preference
|
||||
if (document.getElementById("user_preference")) {
|
||||
user_preference = JSON.parse(document.getElementById("user_preference").textContent)
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
return window.USER_PREF[pref]
|
||||
if (pref) {
|
||||
return user_preference[pref]
|
||||
}
|
||||
return user_preference
|
||||
}
|
||||
|
||||
export function calculateAmount(amount, factor) {
|
||||
@@ -214,6 +220,11 @@ export const ApiMixin = {
|
||||
return {
|
||||
Models: Models,
|
||||
Actions: Actions,
|
||||
FoodCreateDefault: function (form) {
|
||||
form.inherit_ignore = getUserPreference("food_ignore_default")
|
||||
form.inherit = form.supermarket_category.length > 0
|
||||
return form
|
||||
},
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -525,3 +536,11 @@ const specialCases = {
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const formFunctions = {
|
||||
FoodCreateDefault: function (form) {
|
||||
form.fields.filter((x) => x.field === "ignore_inherit")[0].value = getUserPreference("food_ignore_default")
|
||||
form.fields.filter((x) => x.field === "inherit")[0].value = getUserPreference("food_ignore_default").length > 0
|
||||
return form
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user