stored item list, menu component wrapper

This commit is contained in:
Evert Prants 2023-02-03 19:39:38 +02:00
parent b875c2465d
commit 710a904323
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
20 changed files with 559 additions and 392 deletions

11
package-lock.json generated
View File

@ -20,6 +20,7 @@
"pinia": "^2.0.28", "pinia": "^2.0.28",
"sass": "^1.57.1", "sass": "^1.57.1",
"vue": "^3.2.45", "vue": "^3.2.45",
"vue-material-design-icons": "^5.1.2",
"vue-router": "^4.1.6" "vue-router": "^4.1.6"
}, },
"devDependencies": { "devDependencies": {
@ -2007,6 +2008,11 @@
"@vue/shared": "3.2.45" "@vue/shared": "3.2.45"
} }
}, },
"node_modules/vue-material-design-icons": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/vue-material-design-icons/-/vue-material-design-icons-5.1.2.tgz",
"integrity": "sha512-nD1qFM2qAkMlVoe23EfNKIeMfYl6YjHZjSty9q0mwc2gXmPmvEhixywJQhM+VF5KVBI1zAkVTNLoUEERPY10pA=="
},
"node_modules/vue-router": { "node_modules/vue-router": {
"version": "4.1.6", "version": "4.1.6",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.1.6.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.1.6.tgz",
@ -3300,6 +3306,11 @@
"@vue/shared": "3.2.45" "@vue/shared": "3.2.45"
} }
}, },
"vue-material-design-icons": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/vue-material-design-icons/-/vue-material-design-icons-5.1.2.tgz",
"integrity": "sha512-nD1qFM2qAkMlVoe23EfNKIeMfYl6YjHZjSty9q0mwc2gXmPmvEhixywJQhM+VF5KVBI1zAkVTNLoUEERPY10pA=="
},
"vue-router": { "vue-router": {
"version": "4.1.6", "version": "4.1.6",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.1.6.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.1.6.tgz",

View File

@ -21,6 +21,7 @@
"pinia": "^2.0.28", "pinia": "^2.0.28",
"sass": "^1.57.1", "sass": "^1.57.1",
"vue": "^3.2.45", "vue": "^3.2.45",
"vue-material-design-icons": "^5.1.2",
"vue-router": "^4.1.6" "vue-router": "^4.1.6"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,61 +0,0 @@
<template>
<div class="relative" ref="wrapper">
<slot name="trigger" :title="title" :open="open" :toggle="toggle">
<button type="button" @click="() => toggle()" :aria-expanded="open">
<span>{{ title }}</span>
<ChevronDownIcon class="ml-2 h-5 w-5" />
</button>
</slot>
<Transition
name="menu-transition"
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
>
<slot :title="title" :open="open" :toggle="toggle" v-if="open"></slot>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ChevronDownIcon } from '@heroicons/vue/24/outline';
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';
const open = ref(false);
const wrapper = ref();
const props = defineProps<{
title: string;
}>();
const toggle = (to?: boolean) => {
open.value = to ?? !open.value;
};
const event = (e: MouseEvent) => {
if (
wrapper.value.contains(e.target as HTMLElement) &&
!(e.target as HTMLElement).closest('a')
) {
return;
}
open.value = false;
};
onMounted(() => {
window.addEventListener('click', event);
});
onBeforeUnmount(() => {
window.removeEventListener('click', event);
});
onBeforeRouteLeave(() => {
toggle(false);
});
</script>

View File

@ -1,56 +0,0 @@
<template>
<div class="relative">
<Dropdown :title="title">
<template #trigger="{ title, open, toggle }">
<button
type="button"
@click="() => toggle()"
:aria-expanded="open"
:class="[
open ? 'text-gray-900' : 'text-gray-500',
'group inline-flex items-center rounded-md bg-white text-base font-medium hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
]"
>
<span>{{ title }}</span>
<ChevronDownIcon class="ml-2 h-5 w-5" />
</button>
</template>
<template #default="{ title, open, toggle }">
<div
:class="[
positionClass,
'absolute z-10 -ml-4 mt-1 w-screen max-w-xs transform px-2 sm:px-0 lg:ml-0',
]"
>
<div
class="overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5"
>
<slot></slot>
</div>
</div>
</template>
</Dropdown>
</div>
</template>
<script setup lang="ts">
import { ChevronDownIcon } from '@heroicons/vue/24/outline';
import { computed } from '@vue/reactivity';
import { onMounted, ref } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';
import Dropdown from './Dropdown.vue';
const props = defineProps<{
title: string;
position?: 'left' | 'center' | 'right';
}>();
const positionClass = computed(() => {
if (!props.position || props.position === 'center')
return 'left-1/2 -translate-x-1/2';
if (props.position === 'left') return 'left-0';
if (props.position === 'right') return 'right-0';
return '';
});
</script>

View File

@ -3,7 +3,7 @@
<div <div
class="hoverbox relative flex h-60 flex-col overflow-hidden rounded-lg bg-gray-50 shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-500 hover:scale-105 md:h-80" class="hoverbox relative flex h-60 flex-col overflow-hidden rounded-lg bg-gray-50 shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-500 hover:scale-105 md:h-80"
> >
<img v-if="image" :src="image" class="absolute object-cover" /> <img v-if="image" :src="image" alt="" class="absolute object-cover" />
<div class="z-10 mt-auto flex flex-col bg-white px-4 py-4"> <div class="z-10 mt-auto flex flex-col bg-white px-4 py-4">
<span class="text-lg font-bold">{{ title }}</span> <span class="text-lg font-bold">{{ title }}</span>
<span class="text-sm" v-if="subtitle">{{ subtitle }}</span> <span class="text-sm" v-if="subtitle">{{ subtitle }}</span>

View File

@ -1,19 +1,16 @@
<template> <template>
<div <div
class="mb-2 flex items-center justify-between border-b-2 border-gray-100 pb-2" :class="[
'mb-2 flex items-center justify-between pb-2',
bordered ? 'border-b-2 border-gray-100' : '',
]"
> >
<slot /> <slot />
<DropdownButton title="Actions" :position="'right'" v-if="withActions">
<div class="flex flex-col">
<slot name="actions" />
</div>
</DropdownButton>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import DropdownButton from './DropdownButton.vue'; defineProps<{
const props = defineProps<{ bordered?: boolean;
withActions?: boolean;
}>(); }>();
</script> </script>

View File

@ -9,7 +9,7 @@
</div> </div>
<div class="-my-2 -mr-2 md:hidden"> <div class="-my-2 -mr-2 md:hidden">
<PopoverButton <PopoverButton
class="inline-flex items-center justify-center rounded-md bg-white p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500" class="inline-flex items-center justify-center rounded-md bg-white p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
> >
<span class="sr-only">Open menu</span> <span class="sr-only">Open menu</span>
<Bars3Icon class="h-6 w-6" aria-hidden="true" /> <Bars3Icon class="h-6 w-6" aria-hidden="true" />
@ -20,7 +20,7 @@
<PopoverButton <PopoverButton
:class="[ :class="[
open ? 'text-gray-900' : 'text-gray-500', open ? 'text-gray-900' : 'text-gray-500',
'group inline-flex items-center rounded-md bg-white text-base font-medium hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2', 'group inline-flex items-center rounded-md bg-white text-base font-medium hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
]" ]"
> >
<span>Buildings</span> <span>Buildings</span>
@ -91,7 +91,7 @@
</div> </div>
<div class="-mr-2"> <div class="-mr-2">
<PopoverButton <PopoverButton
class="inline-flex items-center justify-center rounded-md bg-white p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500" class="inline-flex items-center justify-center rounded-md bg-white p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
> >
<span class="sr-only">Close menu</span> <span class="sr-only">Close menu</span>
<XMarkIcon class="h-6 w-6" aria-hidden="true" /> <XMarkIcon class="h-6 w-6" aria-hidden="true" />

View File

@ -1,6 +1,7 @@
<template> <template>
<div class="relative"> <Menu title="User" :options="[{ title: 'Logout' }]">
<button <template #trigger>
<MenuButton
class="flex flex-row items-center space-x-2 rounded-full px-1 py-1 pr-2 text-gray-500 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500" class="flex flex-row items-center space-x-2 rounded-full px-1 py-1 pr-2 text-gray-500 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
> >
<div <div
@ -10,13 +11,16 @@
</div> </div>
<span>{{ user.name }}</span> <span>{{ user.name }}</span>
<ChevronDownIcon class="ml-2 h-5 w-5" /> <ChevronDownIcon class="ml-2 h-5 w-5" />
</button> </MenuButton>
</div> </template>
</Menu>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { MenuButton } from '@headlessui/vue';
import { ChevronDownIcon, UserIcon } from '@heroicons/vue/24/outline'; import { ChevronDownIcon, UserIcon } from '@heroicons/vue/24/outline';
import { User } from '../../interfaces/user.interfaces'; import { User } from '../../interfaces/user.interfaces';
import Menu from '../menu/Menu.vue';
const props = defineProps<{ const props = defineProps<{
user: User; user: User;

View File

@ -7,13 +7,32 @@
<template #default="{ closeModal }"> <template #default="{ closeModal }">
<FormAlert :message="error" /> <FormAlert :message="error" />
<Form @submit="onSubmit" v-model="data" :validators="validators"> <Form @submit="onSubmit" v-model="data" :validators="validators">
<FormField name="displayName" label="Display Name" /> <FormField name="displayName" label="Display Name" required />
<FormSelectField :options="selectOptions" name="type" label="Type" /> <FormSelectField
:options="selectOptions"
name="type"
label="Type"
required
/>
<FormField name="locationDescription" label="Location description"> <FormField name="locationDescription" label="Location description">
<span class="text-sm">Describe the location of this storage</span> <span class="text-sm">Describe the location of this storage</span>
</FormField> </FormField>
<FormColorField name="color" label="Color" /> <FormColorField name="color" label="Color" />
<button type="submit">Submit</button> <div class="flex justify-end space-x-1">
<button
class="inline-flex justify-center rounded-md border border-transparent bg-green-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
type="submit"
@click="submitlock = true"
>
Submit and add another
</button>
<button
class="inline-flex justify-center rounded-md border border-transparent bg-green-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
type="submit"
>
Submit
</button>
</div>
</Form> </Form>
</template> </template>
</Modal> </Modal>
@ -56,6 +75,7 @@ const building = ref<BuildingListItem>();
const set = ref<StorageSetListItem | boolean>(); const set = ref<StorageSetListItem | boolean>();
const data = ref({ ...defaults }); const data = ref({ ...defaults });
const error = ref(''); const error = ref('');
const submitlock = ref(false);
const selectOptions = computed(() => { const selectOptions = computed(() => {
const source = set.value === true ? StorageSetTypeName : StorageTypeName; const source = set.value === true ? StorageSetTypeName : StorageTypeName;
return Object.keys(source).reduce<SelectOption[]>( return Object.keys(source).reduce<SelectOption[]>(
@ -82,6 +102,10 @@ const emit = defineEmits<{
(e: 'added', storage: StorageSharedType): void; (e: 'added', storage: StorageSharedType): void;
}>(); }>();
const reset = () => {
data.value = { ...defaults };
};
defineExpose({ defineExpose({
openModal: ( openModal: (
useBuilding: BuildingListItem, useBuilding: BuildingListItem,
@ -91,7 +115,7 @@ defineExpose({
room.value = useRoom; room.value = useRoom;
building.value = useBuilding; building.value = useBuilding;
set.value = useSet; set.value = useSet;
data.value = { ...defaults }; reset();
modalRef.value?.openModal(); modalRef.value?.openModal();
}, },
}); });
@ -133,7 +157,12 @@ const onSubmit = async (value: FormSubmit) => {
); );
} }
modalRef.value?.closeModal();
emit('added', createdStorage); emit('added', createdStorage);
if (submitlock.value) {
submitlock.value = false;
return;
}
modalRef.value?.closeModal();
}; };
</script> </script>

View File

@ -4,7 +4,7 @@
<div <div
:class="[ :class="[
imageClasses, imageClasses,
'relative mt-2 flex items-center justify-center overflow-hidden rounded-md bg-gray-100 ring-1 ring-black ring-opacity-5', 'relative flex flex-shrink-0 items-center justify-center overflow-hidden rounded-md bg-gray-100 ring-1 ring-black ring-opacity-5',
]" ]"
> >
<img <img
@ -15,15 +15,15 @@
/> />
<CubeIcon :class="iconClasses" /> <CubeIcon :class="iconClasses" />
</div> </div>
<div class="flex flex-col"> <div class="-mt-1 flex flex-col">
<div class="flex items-center space-x-1"> <div class="flex flex-col md:flex-row md:items-center md:space-x-1">
<span :class="`text-${size} font-bold`">{{ <span :class="`text-${size} font-bold`">{{
storedItem.item.displayName storedItem.item.displayName
}}</span> }}</span>
<span <span
:class="`mt-0.5 ${fontSize} text-gray-500`" :class="`md:mt-0.5 ${fontSize} text-gray-500`"
v-if="storedItem.addedBy" v-if="storedItem.addedBy"
>· Added ><span class="hidden md:inline">· </span>Added
{{ {{
dateToLocaleString(storedItem.acquiredAt || storedItem.createdAt) dateToLocaleString(storedItem.acquiredAt || storedItem.createdAt)
}} }}
@ -96,13 +96,13 @@ const props = defineProps<{
const imageClasses = computed(() => { const imageClasses = computed(() => {
if (props.size === 'sm') return 'h-8 w-8'; if (props.size === 'sm') return 'h-8 w-8';
if (props.size === 'md') return 'h-12 w-12'; if (props.size === 'md') return 'h-12 w-12';
return 'h-16 w-16'; return 'sm:h-16 sm:w-16 h-8 w-8';
}); });
const iconClasses = computed(() => { const iconClasses = computed(() => {
if (props.size === 'sm') return 'h-4 w-4'; if (props.size === 'sm') return 'h-4 w-4';
if (props.size === 'md') return 'h-8 w-8'; if (props.size === 'md') return 'h-8 w-8';
return 'h-12 w-12'; return 'sm:h-12 sm:w-12 h-4 w-4';
}); });
const fontSize = computed(() => { const fontSize = computed(() => {

View File

@ -0,0 +1,95 @@
<template>
<Menu as="div" class="relative inline-block text-left">
<div>
<slot name="trigger" :title="title">
<MenuButton
:class="[
'inline-flex w-full justify-center rounded-md bg-opacity-20 px-4 py-2',
'text-sm font-medium text-black hover:bg-opacity-30',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-opacity-75',
buttonClass,
]"
>
<slot name="default">
{{ title }}
<ChevronDownIcon
class="ml-2 -mr-1 h-5 w-5"
aria-hidden="true"
v-if="!hideChevron"
/>
</slot>
</MenuButton>
</slot>
</div>
<transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<MenuItems
:class="[
'absolute z-10 mt-2 w-56 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg',
'ring-1 ring-black ring-opacity-5 focus:outline-none',
positionClass,
]"
>
<div class="px-1 py-1">
<MenuItem
as="template"
v-for="(option, index) of options"
:key="option.key || index"
v-slot="{ active }"
>
<slot name="option" :option="option" :active="active">
<RouterLink v-if="option.link" :to="option.link">
<MenuOption :active="active" :option="option" />
</RouterLink>
<MenuOption
v-else
:active="active"
:option="option"
@clicked="() => option.onClick?.()"
/>
</slot>
</MenuItem>
</div>
</MenuItems>
</transition>
</Menu>
</template>
<script setup lang="ts">
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue';
import { ChevronDownIcon } from '@heroicons/vue/24/outline';
import { Component, computed } from 'vue';
import { RouteLocationRaw, RouterLink } from 'vue-router';
import MenuOption from './MenuOption.vue';
export interface MenuOption {
title: string;
key?: string;
link?: RouteLocationRaw;
icon?: Component;
onClick?: () => void;
component?: Component;
}
const props = defineProps<{
title: string;
options: MenuOption[];
buttonClass?: string;
hideChevron?: boolean;
position?: 'left' | 'center' | 'right';
}>();
const positionClass = computed(() => {
if (!props.position || props.position === 'right') return 'right-0';
if (props.position === 'center') return 'left-1/2 -translate-x-1/2';
if (props.position === 'left') return 'left-0';
return '';
});
</script>

View File

@ -0,0 +1,30 @@
<template>
<button
:class="[
active ? 'bg-blue-500 text-white' : 'text-gray-900',
'group flex w-full items-center rounded-md px-2 py-2 text-sm',
]"
@click="emit('clicked')"
>
<component v-if="option.icon" :is="option.icon" class="mr-2 h-5 w-5" />
<component
v-if="option.component"
:is="option.component"
:option="option"
/>
<span v-else>{{ option.title }}</span>
</button>
</template>
<script setup lang="ts">
import type { MenuOption } from './Menu.vue';
const props = defineProps<{
active: boolean;
option: MenuOption;
}>();
const emit = defineEmits<{
(e: 'clicked'): void;
}>();
</script>

View File

@ -1,11 +1,11 @@
import { NavigationGuardWithThis, RouteRecordRaw } from 'vue-router'; import { createWebHistory, RouteRecordRaw } from 'vue-router';
import Dashboard from '../views/Dashboard.vue'; import Dashboard from '../views/Dashboard.vue';
import Login from '../views/Login.vue'; import Login from '../views/Login.vue';
import HousePlanner from '../views/HousePlanner.vue'; import HousePlanner from '../views/HousePlanner.vue';
import BuildingViewBase from '../views/building/BuildingViewBase.vue'; import BuildingViewBase from '../views/building/BuildingViewBase.vue';
import BuildingView from '../views/building/BuildingView.vue'; import BuildingView from '../views/building/BuildingView.vue';
import FloorView from '../views/building/floors/FloorView.vue'; import FloorView from '../views/building/floors/FloorView.vue';
import { createRouter, createWebHashHistory } from 'vue-router'; import { createRouter } from 'vue-router';
import { useUserStore } from '../store/user.store'; import { useUserStore } from '../store/user.store';
import Demo from '../views/Demo.vue'; import Demo from '../views/Demo.vue';
@ -73,7 +73,7 @@ const routes: RouteRecordRaw[] = [
]; ];
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHistory(),
routes, routes,
}); });

View File

@ -1,6 +1,6 @@
<template> <template>
<StandardLayout> <StandardLayout>
<PageHead><h1 class="text-2xl font-bold">Dashboard</h1></PageHead> <PageHead bordered><h1 class="text-2xl font-bold">Dashboard</h1></PageHead>
<p>Hello, {{ user.name }}</p> <p>Hello, {{ user.name }}</p>
@ -20,9 +20,9 @@
</div> </div>
</template> </template>
<PageHead class="mt-4" <PageHead class="mt-4">
><h2 class="text-xl font-bold">Buildings</h2></PageHead <h2 class="text-xl font-bold">Buildings</h2>
> </PageHead>
<div <div
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3" class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"

View File

@ -1,22 +1,22 @@
<template> <template>
<div> <div>
<PageHead with-actions> <PageHead bordered>
<template #actions>
<RouterLink
class="px-2 py-2"
:to="{ name: 'planner', query: { buildingId: building?.id } }"
>Edit floor plans</RouterLink
>
</template>
<template #default>
<div class="flex flex-col"> <div class="flex flex-col">
<h1 class="text-2xl font-bold">{{ building?.displayName }}</h1> <h1 class="text-2xl font-bold">{{ building?.displayName }}</h1>
<span class="text-sm font-light text-gray-800 line-clamp-1">{{ <span class="text-sm font-light text-gray-800 line-clamp-1">{{
building?.address building?.address
}}</span> }}</span>
</div> </div>
</template>
<Menu
title="Actions"
:options="[
{
title: 'Edit floor plans',
link: { name: 'planner', query: { buildingId: building?.id } },
},
]"
/>
</PageHead> </PageHead>
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
@ -38,6 +38,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import Menu from '../../components/menu/Menu.vue';
import PageHead from '../../components/PageHead.vue'; import PageHead from '../../components/PageHead.vue';
import { useBuildingStore } from '../../store/building.store'; import { useBuildingStore } from '../../store/building.store';
import FloorListItem from './floors/FloorListItem.vue'; import FloorListItem from './floors/FloorListItem.vue';

View File

@ -1,34 +1,35 @@
<template> <template>
<PageHead with-actions> <PageHead bordered>
<template #actions>
<RouterLink
class="px-2 py-2"
:to="{
name: 'planner',
query: { buildingId: building?.id, floorId: floor?.id },
}"
>Edit floor plan</RouterLink
>
</template>
<template #default>
<div class="flex flex-col"> <div class="flex flex-col">
<h1 class="text-2xl font-bold">{{ floor?.displayName }}</h1> <h1 class="text-2xl font-bold">{{ floor?.displayName }}</h1>
<span class="text-sm font-light text-gray-800 line-clamp-1" <span class="text-sm font-light text-gray-800 line-clamp-1"
>Floor {{ floor?.number }} in {{ building?.displayName }}</span >Floor {{ floor?.number }} in {{ building?.displayName }}</span
> >
</div> </div>
</template>
<Menu
title="Actions"
:options="[
{
title: 'Edit floor plan',
link: {
name: 'planner',
query: { buildingId: building?.id, floorId: floor?.id },
},
},
]"
/>
</PageHead> </PageHead>
<button <button
@click="showMap = !showMap" @click="showMap = !showMap"
:class="[ :class="[
showMap ? 'bg-gray-100' : 'rounded-md ring-1 ring-black ring-opacity-5', showMap ? 'bg-gray-100' : 'rounded-md ring-1 ring-black ring-opacity-5',
'ml-auto flex items-center space-x-2 rounded-tl-lg rounded-tr-lg px-2 py-2 shadow-md', 'ml-auto flex w-full items-center space-x-2 rounded-tl-lg rounded-tr-lg px-2 py-2 shadow-md md:w-auto',
'focus:outline-none focus:ring-2 focus:ring-blue-500',
]" ]"
> >
<span class="sr-only">{ showMap ? 'Hide' : 'Show' }}</span> Floor plan <span>{{ showMap ? 'Hide' : 'Show' }} interactive floor plan</span>
<ChevronUpIcon v-if="showMap" class="h-4 w-4" /> <ChevronUpIcon v-if="showMap" class="h-4 w-4" />
<ChevronDownIcon v-else class="h-4 w-4" /> <ChevronDownIcon v-else class="h-4 w-4" />
</button> </button>
@ -83,6 +84,7 @@
@mouseenter="() => (hoveredBubble = storage)" @mouseenter="() => (hoveredBubble = storage)"
@start-moving="moveBubble(storage)" @start-moving="moveBubble(storage)"
> >
<div class="flex items-center justify-between">
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<span class="text-md font-bold">{{ <span class="text-md font-bold">{{
storage.displayName storage.displayName
@ -93,6 +95,28 @@
>(Storage Set)</span >(Storage Set)</span
> >
</div> </div>
<div class="flex flex-row items-center space-x-1">
<button
v-if="!isSet(storage)"
@click.stop="selectAndAdd(storage)"
class="rounded-full px-1 py-1 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
title="Insert into Storage"
>
<ArrowDownOnSquareIcon
aria-hidden="true"
class="h-4 w-4"
/>
</button>
<button
@click.stop="selectStorage(storage)"
class="rounded-full px-1 py-1 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
title="Open Storage"
>
<ArrowUpOnSquareIcon aria-hidden="true" class="h-4 w-4" />
</button>
</div>
</div>
<span class="text-sm" v-if="!isSet(storage)" <span class="text-sm" v-if="!isSet(storage)"
>Stored items: >Stored items:
{{ (storage as StorageListItem).itemCount }}</span {{ (storage as StorageListItem).itemCount }}</span
@ -110,6 +134,7 @@
</Transition> </Transition>
<ItemSelector <ItemSelector
:class="{ 'mt-2': !showMap }"
:rooms="rooms" :rooms="rooms"
:storages="storages" :storages="storages"
:selected-room="selectedRoom" :selected-room="selectedRoom"
@ -118,21 +143,26 @@
@select-room="selectRoomFromList" @select-room="selectRoomFromList"
@select-storage="selectStorage" @select-storage="selectStorage"
@new-storage="addNewStorage" @new-storage="addNewStorage"
@add-item="newItem?.openModal(building!, selectedStorage!)"
/> />
<StoredItemCard <StorageView
v-for="stored of selectedStorage?.items || []" v-if="selectedStorage && building"
size="lg" ref="storageViewRef"
:stored-item="stored" @storage-added="newStoredItemAdded"
:storage="selectedStorage"
:building="building"
/> />
<NewStorageModal ref="newStorage" @added="storageAdded" /> <NewStorageModal ref="newStorage" @added="storageAdded" />
<NewItemModal ref="newItem" @added="newStoredItemAdded" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/vue/24/outline'; import {
ArrowDownOnSquareIcon,
ArrowUpOnSquareIcon,
ChevronDownIcon,
ChevronUpIcon,
} from '@heroicons/vue/24/outline';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import PageHead from '../../../components/PageHead.vue'; import PageHead from '../../../components/PageHead.vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
@ -158,10 +188,11 @@ import StorageBubble from './StorageBubble.vue';
import RoomPolygon from './RoomPolygon.vue'; import RoomPolygon from './RoomPolygon.vue';
import { useLocalStorage } from '@vueuse/core'; import { useLocalStorage } from '@vueuse/core';
import isSet from '../../../utils/is-storage-set'; import isSet from '../../../utils/is-storage-set';
import ItemSelector from './ItemSelector.vue'; import ItemSelector from './StorageSelector.vue';
import StoredItemCard from '../../../components/item/StoredItemCard.vue'; import StoredItemCard from '../../../components/item/StoredItemCard.vue';
import NewStorageModal from '../../../components/item/NewStorageModal.vue'; import NewStorageModal from '../../../components/item/NewStorageModal.vue';
import NewItemModal from '../../../components/item/NewItemModal.vue'; import StorageView from './storage/StorageView.vue';
import Menu from '../../../components/menu/Menu.vue';
const route = useRoute(); const route = useRoute();
const buildingStore = useBuildingStore(); const buildingStore = useBuildingStore();
@ -178,7 +209,7 @@ const selectedSet = ref<StorageSetListItem>();
const selectedStorage = ref<StorageListItem>(); const selectedStorage = ref<StorageListItem>();
const newStorage = ref<InstanceType<typeof NewStorageModal>>(); const newStorage = ref<InstanceType<typeof NewStorageModal>>();
const newItem = ref<InstanceType<typeof NewItemModal>>(); const storageViewRef = ref<InstanceType<typeof StorageView>>();
const floor = computed(() => const floor = computed(() =>
building.value?.floors.find( building.value?.floors.find(
@ -250,7 +281,7 @@ const selectRoomFromList = (room: RoomLayoutObject) => {
selectedRoom.value = room; selectedRoom.value = room;
}; };
const selectStorage = ( const selectStorage = async (
storage: StorageSharedType, storage: StorageSharedType,
parentSet?: StorageSetListItem parentSet?: StorageSetListItem
) => { ) => {
@ -262,7 +293,7 @@ const selectStorage = (
selectedSet.value = parentSet; selectedSet.value = parentSet;
selectedStorage.value = storage as StorageListItem; selectedStorage.value = storage as StorageListItem;
jfetch(`${BACKEND_URL}/storage/storages/${storage.id}`, { await jfetch(`${BACKEND_URL}/storage/storages/${storage.id}`, {
headers: authHeader.value, headers: authHeader.value,
}).then(({ data: storage }) => { }).then(({ data: storage }) => {
selectedStorage.value = storage; selectedStorage.value = storage;
@ -333,4 +364,9 @@ const newStoredItemAdded = async (newItem: StoredItem) => {
]; ];
selectedStorage.value.itemCount = selectedStorage.value.items.length; selectedStorage.value.itemCount = selectedStorage.value.items.length;
}; };
const selectAndAdd = async (storage: StorageSharedType) => {
await selectStorage(storage);
storageViewRef.value?.addNewItem();
};
</script> </script>

View File

@ -1,173 +0,0 @@
<template>
<div
:class="[
'flex flex-col md:grid',
selectedSet ? 'md:grid-cols-4' : 'md:grid-cols-3',
]"
>
<div class="flex flex-col">
<button
v-for="room of rooms"
@click="emit('selectRoom', room)"
:class="[
selectedRoom?.id === room.id
? 'bg-blue-100 hover:bg-blue-200'
: 'hover:bg-blue-100',
'flex items-center justify-between border-b-2 border-gray-100 py-2 px-2',
]"
>
<span>{{ room.displayName }}</span> <ChevronRightIcon class="h-4 w-4" />
</button>
</div>
<template v-if="selectedRoom?.id">
<div class="my-2 flex justify-center md:hidden">
<ChevronDoubleDownIcon class="h-6 w-6" />
</div>
<div class="flex flex-col">
<button
v-for="storage of storages"
@click="emit('selectStorage', storage)"
:class="[
(isSet(storage) && selectedSet?.id === storage.id) ||
(selectedStorage?.id === storage.id && !isSet(storage))
? 'bg-blue-100 hover:bg-blue-200'
: 'hover:bg-blue-100',
'flex items-center justify-between border-b-2 border-gray-100 py-2 px-2',
]"
>
<span
>{{ storage.displayName }}
<span v-if="!isSet(storage)"
>({{ (storage as StorageListItem).itemCount }})</span
>
<span v-else>
(set) ({{
(storage as StorageSetListItem).storages.length
}})</span
>
</span>
<ChevronRightIcon class="h-4 w-4" />
</button>
<div class="mx-auto flex space-x-2 py-2">
<button
@click="emit('newStorage', false)"
class="flex items-center space-x-1 text-blue-500 hover:text-blue-600 hover:underline"
>
<PlusIcon class="h-4 w-4" /> <span>Add Storage</span>
</button>
<span>·</span>
<button
@click="emit('newStorage', true)"
class="flex items-center space-x-1 text-blue-500 hover:text-blue-600 hover:underline"
>
<PlusIcon class="h-4 w-4" /> <span>Add Storage Set</span>
</button>
</div>
</div>
</template>
<template v-if="selectedSet">
<div class="my-2 flex justify-center md:hidden">
<ChevronDoubleDownIcon class="h-6 w-6" />
</div>
<div class="flex flex-col">
<button
v-for="storage of selectedSet.storages"
@click="emit('selectStorage', storage, selectedSet)"
:class="[
selectedStorage?.id === storage.id && !isSet(storage)
? 'bg-blue-100 hover:bg-blue-200'
: 'hover:bg-blue-100',
'flex items-center justify-between border-b-2 border-gray-100 py-2 px-2',
]"
>
<span>{{ storage.displayName }} ({{ storage.itemCount }})</span>
<ChevronRightIcon class="h-4 w-4" />
</button>
<div class="mx-auto flex space-x-2 py-2">
<button
@click="emit('newStorage', selectedSet!)"
class="flex items-center space-x-1 text-blue-500 hover:text-blue-600 hover:underline"
>
<PlusIcon class="h-4 w-4" /> <span>Add Storage</span>
</button>
</div>
</div>
</template>
<template v-if="selectedStorage?.items">
<div class="my-2 flex justify-center md:hidden">
<ChevronDoubleDownIcon class="h-6 w-6" />
</div>
<div class="flex flex-col">
<button
v-for="item of selectedStorage.items"
:class="[
'hover:bg-blue-100',
'flex items-center justify-between border-b-2 border-gray-100 py-2 px-2',
]"
>
<span>{{ item.item.displayName }}</span>
<ChevronRightIcon class="h-4 w-4" />
</button>
<div class="mx-auto flex space-x-2 py-2">
<button
@click="emit('addItem', selectedStorage!)"
class="flex items-center space-x-1 text-blue-500 hover:text-blue-600 hover:underline"
>
<PlusIcon class="h-4 w-4" /> <span>Add Items</span>
</button>
</div>
</div>
</template>
<div
class="my-2 mb-4 flex justify-center md:hidden"
v-if="selectedStorage?.items?.length"
>
<ChevronDoubleDownIcon class="h-6 w-6" />
</div>
</div>
</template>
<script setup lang="ts">
import {
ChevronDoubleDownIcon,
ChevronRightIcon,
PlusIcon,
} from '@heroicons/vue/24/outline';
import isSet from '../../../utils/is-storage-set';
import {
StorageListItem,
StorageSetListItem,
StorageSharedType,
} from '../../../interfaces/storage.interfaces';
import { RoomLayoutObject } from '../../../interfaces/room.interfaces';
const props = defineProps<{
rooms: RoomLayoutObject[];
storages: StorageSharedType[];
selectedRoom?: RoomLayoutObject;
selectedSet?: StorageSetListItem;
selectedStorage?: StorageListItem;
}>();
const emit = defineEmits<{
(
e: 'selectStorage',
storage: StorageSharedType,
parentSet?: StorageSetListItem
): void;
(e: 'selectRoom', room: RoomLayoutObject): void;
(e: 'newStorage', set: boolean | StorageSetListItem): void;
(e: 'addItem', storage: StorageListItem): void;
}>();
</script>

View File

@ -0,0 +1,169 @@
<template>
<div>
<div
:class="[
'flex flex-col md:grid',
selectedSet ? 'md:grid-cols-3' : 'md:grid-cols-2',
]"
>
<div class="flex flex-col">
<div
class="border-b-4 border-gray-300 bg-gray-100 px-2 py-2 text-center font-bold"
>
Select Room
</div>
<button
v-for="room of rooms"
@click="emit('selectRoom', room)"
:aria-expanded="selectedRoom?.id === room.id"
:class="[
selectedRoom?.id === room.id
? 'bg-blue-100 hover:bg-blue-200'
: 'hover:bg-blue-100',
'flex items-center justify-between border-b-1 border-gray-100 py-2 px-2',
]"
>
<span>{{ room.displayName }}</span>
<ChevronRightIcon class="h-4 w-4" />
</button>
</div>
<template v-if="selectedRoom?.id">
<div class="my-2 flex justify-center md:hidden">
<ChevronDoubleDownIcon class="h-6 w-6" />
</div>
<div class="flex flex-col">
<div
class="border-b-4 border-gray-300 bg-gray-100 px-2 py-2 text-center font-bold"
>
Select Storage / Set
</div>
<button
v-for="storage of storages"
@click="emit('selectStorage', storage)"
:aria-expanded="isStorageOrSetSelected(storage)"
:class="[
isStorageOrSetSelected(storage)
? 'bg-blue-100 hover:bg-blue-200'
: 'hover:bg-blue-100',
'flex items-center justify-between border-b-1 border-gray-100 py-2 px-2',
]"
>
<span
>{{ storage.displayName }}
<span v-if="!isSet(storage)"
>({{ (storage as StorageListItem).itemCount }})</span
>
<span v-else>
(set) ({{
(storage as StorageSetListItem).storages.length
}})</span
>
</span>
<ChevronRightIcon class="h-4 w-4" />
</button>
<div class="mx-auto flex space-x-2 py-2">
<button
@click="emit('newStorage', false)"
class="flex items-center space-x-1 text-blue-500 hover:text-blue-600 hover:underline"
>
<PlusIcon class="h-4 w-4" /> <span>Add Storage</span>
</button>
<span>·</span>
<button
@click="emit('newStorage', true)"
class="flex items-center space-x-1 text-blue-500 hover:text-blue-600 hover:underline"
>
<PlusIcon class="h-4 w-4" /> <span>Add Storage Set</span>
</button>
</div>
</div>
</template>
<template v-if="selectedSet">
<div class="my-2 flex justify-center md:hidden">
<ChevronDoubleDownIcon class="h-6 w-6" />
</div>
<div class="flex flex-col">
<div
class="border-b-4 border-gray-300 bg-gray-100 px-2 py-2 text-center font-bold"
>
Select Storage
</div>
<button
v-for="storage of selectedSet.storages"
@click="emit('selectStorage', storage, selectedSet)"
:aria-expanded="isStorageSelected(storage)"
:class="[
isStorageSelected(storage)
? 'bg-blue-100 hover:bg-blue-200'
: 'hover:bg-blue-100',
'flex items-center justify-between border-b-1 border-gray-100 py-2 px-2',
]"
>
<span>{{ storage.displayName }} ({{ storage.itemCount }})</span>
<ChevronRightIcon class="h-4 w-4" />
</button>
<div class="mx-auto flex space-x-2 py-2">
<button
@click="emit('newStorage', selectedSet!)"
class="flex items-center space-x-1 text-blue-500 hover:text-blue-600 hover:underline"
>
<PlusIcon class="h-4 w-4" /> <span>Add Storage</span>
</button>
</div>
</div>
</template>
</div>
<div class="my-2 mb-4 flex justify-center" v-if="selectedStorage">
<ChevronDoubleDownIcon class="h-6 w-6" />
</div>
</div>
</template>
<script setup lang="ts">
import {
ChevronDoubleDownIcon,
ChevronRightIcon,
PlusIcon,
} from '@heroicons/vue/24/outline';
import isSet from '../../../utils/is-storage-set';
import {
StorageListItem,
StorageSetListItem,
StorageSharedType,
} from '../../../interfaces/storage.interfaces';
import { RoomLayoutObject } from '../../../interfaces/room.interfaces';
const props = defineProps<{
rooms: RoomLayoutObject[];
storages: StorageSharedType[];
selectedRoom?: RoomLayoutObject;
selectedSet?: StorageSetListItem;
selectedStorage?: StorageListItem;
}>();
const emit = defineEmits<{
(
e: 'selectStorage',
storage: StorageSharedType,
parentSet?: StorageSetListItem
): void;
(e: 'selectRoom', room: RoomLayoutObject): void;
(e: 'newStorage', set: boolean | StorageSetListItem): void;
(e: 'addItem', storage: StorageListItem): void;
}>();
const isStorageOrSetSelected = (storage: StorageSharedType) =>
(isSet(storage) && props.selectedSet?.id === storage.id) ||
(props.selectedStorage?.id === storage.id && !isSet(storage));
const isStorageSelected = (storage: StorageSharedType) =>
props.selectedStorage?.id === storage.id && !isSet(storage);
</script>

View File

@ -0,0 +1,80 @@
<template>
<div>
<PageHead bordered>
<h2 class="text-2xl font-bold">{{ storage.displayName }}</h2>
<Menu
title="Actions"
:options="[
{
title: 'Add new item',
onClick: addNewItem,
},
]"
/>
</PageHead>
<span class="text-xl font-bold text-gray-400" v-if="!storage.items?.length"
>No items in storage.</span
>
<div class="grid gap-2 md:grid-cols-2" v-else>
<StoredItemCard
v-for="stored of storage.items || []"
class="rounded-md bg-white px-2 py-2 shadow-md ring-1 ring-black ring-opacity-5"
size="lg"
:stored-item="stored"
>
<template #actions>
<Menu
title=""
button-class="px-0.5 py-0.5"
:options="[
{ title: 'Change details' },
{ title: 'Add transaction' },
]"
>
<EllipsisVerticalIcon class="h-5 w-5" />
</Menu>
</template>
</StoredItemCard>
</div>
<NewItemModal ref="newItem" @added="newStoredItemAdded" />
</div>
</template>
<script setup lang="ts">
import { EllipsisVerticalIcon } from '@heroicons/vue/24/outline';
import { ref } from 'vue';
import NewItemModal from '../../../../components/item/NewItemModal.vue';
import StoredItemCard from '../../../../components/item/StoredItemCard.vue';
import Menu from '../../../../components/menu/Menu.vue';
import PageHead from '../../../../components/PageHead.vue';
import { BuildingListItem } from '../../../../interfaces/building.interfaces';
import {
StorageListItem,
StoredItem,
} from '../../../../interfaces/storage.interfaces';
const newItem = ref<InstanceType<typeof NewItemModal>>();
const props = defineProps<{
storage: StorageListItem;
building: BuildingListItem;
}>();
const emit = defineEmits<{
(e: 'storageAdded', item: StoredItem): void;
}>();
const newStoredItemAdded = (item: StoredItem) => {
emit('storageAdded', item);
};
const addNewItem = () => {
newItem.value?.openModal(props.building, props.storage);
};
defineExpose({
addNewItem,
});
</script>

View File

@ -2,7 +2,11 @@
module.exports = { module.exports = {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: { theme: {
extend: {}, extend: {
borderWidth: {
1: '1px',
},
},
}, },
plugins: [require('@tailwindcss/forms'), require('@tailwindcss/line-clamp')], plugins: [require('@tailwindcss/forms'), require('@tailwindcss/line-clamp')],
}; };