8f569509bf
* update default color * move labels editor
579 lines
16 KiB
Vue
579 lines
16 KiB
Vue
<template>
|
|
<v-container v-if="shoppingList" class="md-container">
|
|
<BasePageTitle divider>
|
|
<template #header>
|
|
<v-img max-height="100" max-width="100" :src="require('~/static/svgs/shopping-cart.svg')"></v-img>
|
|
</template>
|
|
<template #title> {{ shoppingList.name }} </template>
|
|
</BasePageTitle>
|
|
|
|
<!-- Viewer -->
|
|
<section v-if="!edit" class="py-2">
|
|
<div v-if="!byLabel">
|
|
<draggable :value="shoppingList.listItems" handle=".handle" @input="updateIndex">
|
|
<v-lazy v-for="(item, index) in listItems.unchecked" :key="item.id">
|
|
<ShoppingListItem
|
|
v-model="listItems.unchecked[index]"
|
|
class="my-2 my-sm-0"
|
|
:labels="allLabels"
|
|
:units="allUnits || []"
|
|
:foods="allFoods || []"
|
|
@checked="saveListItem(item)"
|
|
@save="saveListItem(item)"
|
|
@delete="deleteListItem(item)"
|
|
/>
|
|
</v-lazy>
|
|
</draggable>
|
|
</div>
|
|
|
|
<!-- View By Label -->
|
|
<div v-else>
|
|
<div v-for="(value, key) in itemsByLabel" :key="key" class="mb-6">
|
|
<div @click="toggleShowChecked()">
|
|
<span>
|
|
<v-icon>
|
|
{{ $globals.icons.tags }}
|
|
</v-icon>
|
|
</span>
|
|
{{ key }}
|
|
</div>
|
|
<v-lazy v-for="(item, index) in value" :key="item.id">
|
|
<ShoppingListItem
|
|
v-model="value[index]"
|
|
:labels="allLabels"
|
|
:units="allUnits || []"
|
|
:foods="allFoods || []"
|
|
@checked="saveListItem(item)"
|
|
@save="saveListItem(item)"
|
|
@delete="deleteListItem(item)"
|
|
/>
|
|
</v-lazy>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create Item -->
|
|
<div v-if="createEditorOpen">
|
|
<ShoppingListItemEditor
|
|
v-model="createListItemData"
|
|
class="my-4"
|
|
:labels="allLabels"
|
|
:units="allUnits || []"
|
|
:foods="allFoods || []"
|
|
@delete="createEditorOpen = false"
|
|
@cancel="createEditorOpen = false"
|
|
@save="createListItem"
|
|
/>
|
|
</div>
|
|
<div v-else class="mt-4 d-flex justify-end">
|
|
<BaseButton create @click="createEditorOpen = true" />
|
|
</div>
|
|
|
|
<!-- Action Bar -->
|
|
<div class="d-flex justify-end mb-4 mt-2">
|
|
<BaseButtonGroup
|
|
:buttons="[
|
|
{
|
|
icon: $globals.icons.contentCopy,
|
|
text: '',
|
|
event: 'edit',
|
|
children: [
|
|
{
|
|
icon: $globals.icons.contentCopy,
|
|
text: 'Copy as Text',
|
|
event: 'copy-plain',
|
|
},
|
|
{
|
|
icon: $globals.icons.contentCopy,
|
|
text: 'Copy as Markdown',
|
|
event: 'copy-markdown',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
icon: $globals.icons.delete,
|
|
text: 'Delete Checked',
|
|
event: 'delete',
|
|
},
|
|
{
|
|
icon: $globals.icons.tags,
|
|
text: 'Toggle Label Sort',
|
|
event: 'sort-by-labels',
|
|
},
|
|
{
|
|
icon: $globals.icons.checkboxBlankOutline,
|
|
text: 'Uncheck All Items',
|
|
event: 'uncheck',
|
|
},
|
|
]"
|
|
@edit="edit = true"
|
|
@delete="deleteChecked"
|
|
@uncheck="uncheckAll"
|
|
@sort-by-labels="sortByLabels"
|
|
@copy-plain="copyListItems('plain')"
|
|
@copy-markdown="copyListItems('markdown')"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Checked Items -->
|
|
<div v-if="listItems.checked && listItems.checked.length > 0" class="mt-6">
|
|
<button @click="toggleShowChecked()">
|
|
<span>
|
|
<v-icon>
|
|
{{ showChecked ? $globals.icons.chevronDown : $globals.icons.chevronRight }}
|
|
</v-icon>
|
|
</span>
|
|
{{ listItems.checked ? listItems.checked.length : 0 }} items checked
|
|
</button>
|
|
<v-divider class="my-4"></v-divider>
|
|
<v-expand-transition>
|
|
<div v-show="showChecked">
|
|
<div v-for="(item, idx) in listItems.checked" :key="item.id">
|
|
<ShoppingListItem
|
|
v-model="listItems.checked[idx]"
|
|
class="strike-through-note"
|
|
:labels="allLabels"
|
|
:units="allUnits || []"
|
|
:foods="allFoods || []"
|
|
@checked="saveListItem(item)"
|
|
@save="saveListItem(item)"
|
|
@delete="deleteListItem(item)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</v-expand-transition>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Recipe References -->
|
|
<v-lazy v-if="shoppingList.recipeReferences && shoppingList.recipeReferences.length > 0">
|
|
<section>
|
|
<div>
|
|
<span>
|
|
<v-icon left class="mb-1">
|
|
{{ $globals.icons.primary }}
|
|
</v-icon>
|
|
</span>
|
|
{{ shoppingList.recipeReferences ? shoppingList.recipeReferences.length : 0 }} Linked Recipes
|
|
</div>
|
|
<v-divider class="my-4"></v-divider>
|
|
<RecipeList :recipes="listRecipes">
|
|
<template v-for="(recipe, index) in listRecipes" #[`actions-${recipe.id}`]>
|
|
<v-list-item-action :key="'item-actions-decrease' + recipe.id">
|
|
<v-btn icon @click.prevent="removeRecipeReferenceToList(recipe.id)">
|
|
<v-icon color="grey lighten-1">{{ $globals.icons.minus }}</v-icon>
|
|
</v-btn>
|
|
</v-list-item-action>
|
|
<div :key="'item-actions-quantity' + recipe.id" class="pl-3">
|
|
{{ shoppingList.recipeReferences[index].recipeQuantity }}
|
|
</div>
|
|
<v-list-item-action :key="'item-actions-increase' + recipe.id">
|
|
<v-btn icon @click.prevent="addRecipeReferenceToList(recipe.id)">
|
|
<v-icon color="grey lighten-1">{{ $globals.icons.createAlt }}</v-icon>
|
|
</v-btn>
|
|
</v-list-item-action>
|
|
</template>
|
|
</RecipeList>
|
|
</section>
|
|
</v-lazy>
|
|
|
|
<v-lazy>
|
|
<div class="d-flex justify-end mt-10">
|
|
<ButtonLink to="/group/data/labels" text="Manage Labels" :icon="$globals.icons.tags" />
|
|
</div>
|
|
</v-lazy>
|
|
</v-container>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import draggable from "vuedraggable";
|
|
|
|
import { defineComponent, useAsync, useRoute, computed, ref } from "@nuxtjs/composition-api";
|
|
import { useToggle } from "@vueuse/core";
|
|
import { useCopyList } from "~/composables/use-copy";
|
|
import { useUserApi } from "~/composables/api";
|
|
import { useAsyncKey } from "~/composables/use-utils";
|
|
import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue";
|
|
import { MultiPurposeLabelOut } from "~/types/api-types/labels";
|
|
import { ShoppingListItemCreate, ShoppingListItemOut } from "~/types/api-types/group";
|
|
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
|
|
import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue";
|
|
import { getDisplayText } from "~/composables/use-display-text";
|
|
|
|
type CopyTypes = "plain" | "markdown";
|
|
|
|
interface PresentLabel {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
export default defineComponent({
|
|
components: {
|
|
draggable,
|
|
ShoppingListItem,
|
|
RecipeList,
|
|
ShoppingListItemEditor,
|
|
},
|
|
setup() {
|
|
const userApi = useUserApi();
|
|
|
|
const edit = ref(false);
|
|
const byLabel = ref(false);
|
|
|
|
const route = useRoute();
|
|
const id = route.value.params.id;
|
|
|
|
// ===============================================================
|
|
// Shopping List Actions
|
|
|
|
const shoppingList = useAsync(async () => {
|
|
return await fetchShoppingList();
|
|
}, useAsyncKey());
|
|
|
|
async function fetchShoppingList() {
|
|
const { data } = await userApi.shopping.lists.getOne(id);
|
|
return data;
|
|
}
|
|
|
|
async function refresh() {
|
|
shoppingList.value = await fetchShoppingList();
|
|
}
|
|
|
|
// =====================================
|
|
// List Item CRUD
|
|
|
|
const listItems = computed(() => {
|
|
return {
|
|
checked: shoppingList.value?.listItems?.filter((item) => item.checked) ?? [],
|
|
unchecked: shoppingList.value?.listItems?.filter((item) => !item.checked) ?? [],
|
|
};
|
|
});
|
|
|
|
const [showChecked, toggleShowChecked] = useToggle(false);
|
|
|
|
// =====================================
|
|
// Copy List Items
|
|
|
|
const copy = useCopyList();
|
|
|
|
function copyListItems(copyType: CopyTypes) {
|
|
const items = shoppingList.value?.listItems?.filter((item) => !item.checked);
|
|
|
|
if (!items) {
|
|
return;
|
|
}
|
|
|
|
const text = items.map((itm) => getDisplayText(itm.note, itm.quantity, itm.food, itm.unit));
|
|
|
|
switch (copyType) {
|
|
case "markdown":
|
|
copy.copyMarkdownCheckList(text);
|
|
break;
|
|
default:
|
|
copy.copyPlain(text);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// =====================================
|
|
// Check / Uncheck All
|
|
|
|
function uncheckAll() {
|
|
let hasChanged = false;
|
|
shoppingList.value?.listItems?.forEach((item) => {
|
|
if (item.checked) {
|
|
hasChanged = true;
|
|
item.checked = false;
|
|
}
|
|
});
|
|
if (hasChanged) {
|
|
updateListItems();
|
|
}
|
|
}
|
|
|
|
function deleteChecked() {
|
|
const checked = shoppingList.value?.listItems?.filter((item) => item.checked);
|
|
|
|
if (!checked || checked?.length === 0) {
|
|
return;
|
|
}
|
|
|
|
deleteListItems(checked);
|
|
|
|
refresh();
|
|
}
|
|
|
|
// =====================================
|
|
// List Item Context Menu
|
|
|
|
const contextActions = {
|
|
delete: "delete",
|
|
setIngredient: "setIngredient",
|
|
};
|
|
|
|
const contextMenu = [
|
|
{ title: "Delete", action: contextActions.delete },
|
|
{ title: "Ingredient", action: contextActions.setIngredient },
|
|
];
|
|
|
|
function contextMenuAction(action: string, item: ShoppingListItemOut, idx: number) {
|
|
if (!shoppingList.value?.listItems) {
|
|
return;
|
|
}
|
|
|
|
switch (action) {
|
|
case contextActions.delete:
|
|
shoppingList.value.listItems = shoppingList.value?.listItems.filter((itm) => itm.id !== item.id);
|
|
break;
|
|
case contextActions.setIngredient:
|
|
shoppingList.value.listItems[idx].isFood = !shoppingList.value.listItems[idx].isFood;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// =====================================
|
|
// Labels, Units, Foods
|
|
// TODO: Extract to Composable
|
|
|
|
const allLabels = ref([] as MultiPurposeLabelOut[]);
|
|
|
|
const allUnits = useAsync(async () => {
|
|
const { data } = await userApi.units.getAll();
|
|
return data ?? [];
|
|
}, useAsyncKey());
|
|
|
|
const allFoods = useAsync(async () => {
|
|
const { data } = await userApi.foods.getAll();
|
|
return data ?? [];
|
|
}, useAsyncKey());
|
|
|
|
function sortByLabels() {
|
|
byLabel.value = !byLabel.value;
|
|
}
|
|
|
|
const presentLabels = computed(() => {
|
|
const labels: PresentLabel[] = [];
|
|
|
|
shoppingList.value?.listItems?.forEach((item) => {
|
|
if (item.labelId && item.label) {
|
|
labels.push({
|
|
name: item.label.name,
|
|
id: item.labelId,
|
|
});
|
|
}
|
|
});
|
|
|
|
return labels;
|
|
});
|
|
|
|
const itemsByLabel = computed(() => {
|
|
const items: { [prop: string]: ShoppingListItemCreate[] } = {};
|
|
|
|
const noLabel = {
|
|
"No Label": [] as ShoppingListItemCreate[],
|
|
};
|
|
|
|
shoppingList.value?.listItems?.forEach((item) => {
|
|
if (item.checked) {
|
|
return;
|
|
}
|
|
|
|
if (item.labelId) {
|
|
if (item.label && item.label.name in items) {
|
|
items[item.label.name].push(item);
|
|
} else if (item.label) {
|
|
items[item.label.name] = [item];
|
|
}
|
|
} else {
|
|
noLabel["No Label"].push(item);
|
|
}
|
|
});
|
|
|
|
if (noLabel["No Label"].length > 0) {
|
|
items["No Label"] = noLabel["No Label"];
|
|
}
|
|
|
|
return items;
|
|
});
|
|
|
|
async function refreshLabels() {
|
|
const { data } = await userApi.multiPurposeLabels.getAll();
|
|
allLabels.value = data ?? [];
|
|
}
|
|
|
|
refreshLabels();
|
|
|
|
// =====================================
|
|
// Add/Remove Recipe References
|
|
|
|
const listRecipes = computed<Array<any>>(() => {
|
|
return shoppingList.value?.recipeReferences?.map((ref) => ref.recipe) ?? [];
|
|
});
|
|
|
|
async function addRecipeReferenceToList(recipeId: string) {
|
|
if (!shoppingList.value) {
|
|
return;
|
|
}
|
|
|
|
const { data } = await userApi.shopping.lists.addRecipe(shoppingList.value.id, recipeId);
|
|
|
|
if (data) {
|
|
refresh();
|
|
}
|
|
}
|
|
|
|
async function removeRecipeReferenceToList(recipeId: string) {
|
|
if (!shoppingList.value) {
|
|
return;
|
|
}
|
|
|
|
const { data } = await userApi.shopping.lists.removeRecipe(shoppingList.value.id, recipeId);
|
|
|
|
if (data) {
|
|
refresh();
|
|
}
|
|
}
|
|
|
|
// =====================================
|
|
// List Item CRUD
|
|
|
|
async function saveListItem(item: ShoppingListItemOut) {
|
|
if (!shoppingList.value) {
|
|
return;
|
|
}
|
|
|
|
const { data } = await userApi.shopping.items.updateOne(item.id, item);
|
|
|
|
if (data) {
|
|
refresh();
|
|
}
|
|
}
|
|
|
|
async function deleteListItem(item: ShoppingListItemOut) {
|
|
if (!shoppingList.value) {
|
|
return;
|
|
}
|
|
|
|
const { data } = await userApi.shopping.items.deleteOne(item.id);
|
|
|
|
if (data) {
|
|
refresh();
|
|
}
|
|
}
|
|
|
|
// =====================================
|
|
// Create New Item
|
|
|
|
const createEditorOpen = ref(false);
|
|
const createListItemData = ref<ShoppingListItemCreate>(ingredientResetFactory());
|
|
|
|
function ingredientResetFactory(): ShoppingListItemCreate {
|
|
return {
|
|
shoppingListId: id,
|
|
checked: false,
|
|
position: shoppingList.value?.listItems?.length || 1,
|
|
isFood: false,
|
|
quantity: 1,
|
|
note: "",
|
|
unit: undefined,
|
|
food: undefined,
|
|
labelId: undefined,
|
|
};
|
|
}
|
|
|
|
async function createListItem() {
|
|
if (!shoppingList.value) {
|
|
return;
|
|
}
|
|
|
|
const { data } = await userApi.shopping.items.createOne(createListItemData.value);
|
|
|
|
if (data) {
|
|
createListItemData.value = ingredientResetFactory();
|
|
createEditorOpen.value = false;
|
|
refresh();
|
|
}
|
|
}
|
|
|
|
function updateIndex(data: ShoppingListItemOut[]) {
|
|
if (shoppingList.value?.listItems) {
|
|
shoppingList.value.listItems = data;
|
|
}
|
|
|
|
updateListItems();
|
|
}
|
|
|
|
async function deleteListItems(items: ShoppingListItemOut[]) {
|
|
if (!shoppingList.value) {
|
|
return;
|
|
}
|
|
|
|
const { data } = await userApi.shopping.items.deleteMany(items);
|
|
|
|
if (data) {
|
|
refresh();
|
|
}
|
|
}
|
|
|
|
async function updateListItems() {
|
|
if (!shoppingList.value?.listItems) {
|
|
return;
|
|
}
|
|
|
|
// Set Position
|
|
shoppingList.value.listItems = shoppingList.value.listItems.map((itm: ShoppingListItemOut, idx: number) => {
|
|
itm.position = idx;
|
|
return itm;
|
|
});
|
|
|
|
const { data } = await userApi.shopping.items.updateMany(shoppingList.value.listItems);
|
|
|
|
if (data) {
|
|
refresh();
|
|
}
|
|
}
|
|
|
|
return {
|
|
addRecipeReferenceToList,
|
|
updateListItems,
|
|
allLabels,
|
|
byLabel,
|
|
contextMenu,
|
|
contextMenuAction,
|
|
copyListItems,
|
|
createEditorOpen,
|
|
createListItem,
|
|
createListItemData,
|
|
deleteChecked,
|
|
deleteListItem,
|
|
edit,
|
|
itemsByLabel,
|
|
listItems,
|
|
listRecipes,
|
|
presentLabels,
|
|
removeRecipeReferenceToList,
|
|
saveListItem,
|
|
shoppingList,
|
|
showChecked,
|
|
sortByLabels,
|
|
toggleShowChecked,
|
|
uncheckAll,
|
|
updateIndex,
|
|
allUnits,
|
|
allFoods,
|
|
};
|
|
},
|
|
head() {
|
|
return {
|
|
title: this.$t("shopping-list.shopping-list") as string,
|
|
};
|
|
},
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.number-input-container {
|
|
max-width: 50px;
|
|
}
|
|
</style>
|