DIY forms
This commit is contained in:
parent
01fbae4fec
commit
dfab6b8a18
96
src/components/Modal.vue
Normal file
96
src/components/Modal.vue
Normal file
@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<TransitionRoot appear :show="isOpen" as="template">
|
||||
<Dialog as="div" @close="closeModal" class="relative z-10">
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="duration-300 ease-out"
|
||||
enter-from="opacity-0"
|
||||
enter-to="opacity-100"
|
||||
leave="duration-200 ease-in"
|
||||
leave-from="opacity-100"
|
||||
leave-to="opacity-0"
|
||||
>
|
||||
<div class="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</TransitionChild>
|
||||
|
||||
<div class="fixed inset-0 overflow-y-auto">
|
||||
<div
|
||||
class="flex min-h-full items-center justify-center p-4 text-center"
|
||||
>
|
||||
<TransitionChild
|
||||
as="template"
|
||||
enter="duration-300 ease-out"
|
||||
enter-from="opacity-0 scale-95"
|
||||
enter-to="opacity-100 scale-100"
|
||||
leave="duration-200 ease-in"
|
||||
leave-from="opacity-100 scale-100"
|
||||
leave-to="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel
|
||||
:class="`w-full ${maxWidth} transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all`"
|
||||
>
|
||||
<DialogTitle
|
||||
as="h3"
|
||||
class="text-lg font-bold leading-6 text-gray-900"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<slot name="title" />
|
||||
<button v-if="closeButton" @click="closeModal">
|
||||
<XMarkIcon class="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<slot :closeModal="closeModal" />
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import {
|
||||
TransitionRoot,
|
||||
TransitionChild,
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
} from '@headlessui/vue';
|
||||
import { XMarkIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
closeButton?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
}>(),
|
||||
{
|
||||
closeButton: true,
|
||||
size: 'sm',
|
||||
}
|
||||
);
|
||||
|
||||
function closeModal() {
|
||||
isOpen.value = false;
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
isOpen.value = true;
|
||||
}
|
||||
|
||||
const maxWidth = computed(() => {
|
||||
if (props.size === 'sm') return 'max-w-md';
|
||||
if (props.size === 'md') return 'max-w-xl';
|
||||
if (props.size === 'lg') return 'max-w-2xl';
|
||||
return 'max-w-4xl';
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
openModal,
|
||||
closeModal,
|
||||
isOpen,
|
||||
});
|
||||
</script>
|
102
src/components/form/Form.vue
Normal file
102
src/components/form/Form.vue
Normal file
@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<div class="flex flex-col space-y-5">
|
||||
<slot />
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDebounceFn } from '@vueuse/shared';
|
||||
import { provide, ref, watch } from 'vue';
|
||||
import deepUnref from '../../utils/deep-unref';
|
||||
import { FormData, FormErrors, FormSubmit } from './form.types';
|
||||
import { FormValidator } from './validator.types';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: FormData;
|
||||
errors?: FormErrors;
|
||||
validators?: FormValidator[];
|
||||
}>(),
|
||||
{
|
||||
modelValue: () => ({}),
|
||||
errors: () => ({}),
|
||||
validators: () => [],
|
||||
}
|
||||
);
|
||||
|
||||
const isValid = ref(true);
|
||||
const invalidFields = ref<string[]>([]);
|
||||
const formData = ref<FormData>(props.modelValue);
|
||||
const formErrors = ref<FormErrors>(props.errors);
|
||||
const validateField = async (field: string) => {
|
||||
let valid = true;
|
||||
if (formErrors.value[field]) {
|
||||
delete formErrors.value[field];
|
||||
if (invalidFields.value.includes(field)) {
|
||||
invalidFields.value = invalidFields.value.filter((x) => x !== field);
|
||||
}
|
||||
}
|
||||
|
||||
for (const validator of props.validators) {
|
||||
if (validator.field !== field) continue;
|
||||
for (const fn of validator.validators) {
|
||||
const result = await fn(field, formData.value[field], formData.value);
|
||||
if (!result.isValid) {
|
||||
formErrors.value = {
|
||||
...formErrors.value,
|
||||
[field]: [...(formErrors.value[field] || []), result.message],
|
||||
};
|
||||
invalidFields.value.push(field);
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!valid && isValid.value) {
|
||||
isValid.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isValid.value = !invalidFields.value.length;
|
||||
};
|
||||
|
||||
const validateAll = async () => {
|
||||
const fields = props.validators
|
||||
.map((validator) => validator.field)
|
||||
.filter((value, index, array) => array.indexOf(value) === index);
|
||||
if (!fields.length) return;
|
||||
return Promise.allSettled(fields.map((field) => validateField(field)));
|
||||
};
|
||||
|
||||
const fieldChange = useDebounceFn(validateField, 300);
|
||||
|
||||
provide('formData', formData);
|
||||
provide('formErrors', formErrors);
|
||||
provide('fieldChange', fieldChange);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'submit', data: FormSubmit): void;
|
||||
(e: 'update:modelValue', data: FormData): void;
|
||||
(e: 'update:errors', errors: FormErrors): void;
|
||||
(e: 'update:validity', validity: boolean): void;
|
||||
}>();
|
||||
|
||||
watch(formData, (val) => emit('update:modelValue', val), { deep: true });
|
||||
watch(formErrors, (val) => emit('update:errors', val));
|
||||
watch(isValid, (val) => emit('update:validity', val));
|
||||
|
||||
const onSubmit = async () => {
|
||||
await validateAll();
|
||||
emit('submit', {
|
||||
isValid: isValid.value,
|
||||
formData: deepUnref(formData.value),
|
||||
fieldErrors: deepUnref(formErrors.value),
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
isValid,
|
||||
});
|
||||
</script>
|
103
src/components/form/FormField.vue
Normal file
103
src/components/form/FormField.vue
Normal file
@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="flex flex-col space-y-1">
|
||||
<label
|
||||
:for="forId"
|
||||
:class="[
|
||||
'block text-sm font-medium transition-colors duration-200',
|
||||
errors.length ? 'text-red-500' : 'text-gray-700',
|
||||
]"
|
||||
>{{ label }}</label
|
||||
>
|
||||
<input
|
||||
:type="type"
|
||||
:id="forId"
|
||||
:name="name"
|
||||
:value="value"
|
||||
:class="inputClass"
|
||||
:placeholder="placeholder"
|
||||
@input="onInput($event)"
|
||||
@change="onChange($event)"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<slot />
|
||||
<span
|
||||
class="text-sm text-red-500"
|
||||
v-for="error of errors"
|
||||
aria-live="assertive"
|
||||
>{{ error }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, Ref } from 'vue';
|
||||
import { inject } from 'vue';
|
||||
import { FormData, FormErrors } from './form.types';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
type?:
|
||||
| 'text'
|
||||
| 'number'
|
||||
| 'password'
|
||||
| 'color'
|
||||
| 'email'
|
||||
| 'checkbox'
|
||||
| 'radio';
|
||||
label: string;
|
||||
name: string;
|
||||
placeholder?: string;
|
||||
}>(),
|
||||
{
|
||||
type: 'text',
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'change', value: unknown): void;
|
||||
(e: 'focus'): void;
|
||||
(e: 'blur'): void;
|
||||
}>();
|
||||
|
||||
const formData = inject<Ref<FormData>>('formData');
|
||||
const formErrors = inject<Ref<FormErrors>>('formErrors');
|
||||
const fieldChange = inject<(field: string) => void>('fieldChange');
|
||||
const forId = computed(() => `form-${props.name}`);
|
||||
const value = computed(() => formData?.value[props.name]);
|
||||
const errors = computed(() => formErrors?.value[props.name] || []);
|
||||
const inputClass = computed(() => {
|
||||
return [
|
||||
errors.value.length
|
||||
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500',
|
||||
`mt-1 block w-full rounded-md shadow-sm sm:text-sm transition-colors duration-200`,
|
||||
];
|
||||
});
|
||||
|
||||
const onInput = (ev: Event) => {
|
||||
if (!formData) return;
|
||||
const value = (ev.target as HTMLInputElement).value;
|
||||
const cleanValue = props.type === 'number' ? parseFloat(value) : value;
|
||||
formData.value[props.name] = cleanValue;
|
||||
emit('change', cleanValue);
|
||||
fieldChange?.(props.name);
|
||||
};
|
||||
|
||||
const onChange = (ev: Event) => {
|
||||
if (!formData) return;
|
||||
if (props.type === 'checkbox' || props.type === 'radio') {
|
||||
const cleanValue = (ev.target as HTMLInputElement).checked;
|
||||
formData.value[props.name] = cleanValue;
|
||||
emit('change', cleanValue);
|
||||
fieldChange?.(props.name);
|
||||
}
|
||||
};
|
||||
|
||||
const onFocus = () => emit('focus');
|
||||
|
||||
const onBlur = () => {
|
||||
emit('blur');
|
||||
fieldChange?.(props.name);
|
||||
};
|
||||
</script>
|
7
src/components/form/form.types.ts
Normal file
7
src/components/form/form.types.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export type FormData = Record<string, unknown>;
|
||||
export type FormErrors = Record<string, string[]>;
|
||||
export interface FormSubmit {
|
||||
isValid: boolean;
|
||||
formData: FormData;
|
||||
fieldErrors: FormErrors;
|
||||
}
|
15
src/components/form/validator.types.ts
Normal file
15
src/components/form/validator.types.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export interface ValidState {
|
||||
isValid: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type FormValidatorFn = (
|
||||
field: string,
|
||||
value: unknown,
|
||||
formData: Record<string, unknown>
|
||||
) => ValidState | Promise<ValidState>;
|
||||
|
||||
export interface FormValidator {
|
||||
field: string;
|
||||
validators: FormValidatorFn[];
|
||||
}
|
44
src/components/form/validators.ts
Normal file
44
src/components/form/validators.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { FormValidatorFn } from './validator.types';
|
||||
|
||||
export function MinLength(length: number, message?: string): FormValidatorFn {
|
||||
return (_: string, value: unknown) => {
|
||||
let isValid = true;
|
||||
if (value != null && typeof value === 'string' && value.length < length)
|
||||
isValid = false;
|
||||
return {
|
||||
isValid,
|
||||
message: message || `Must be at least ${length} characters long`,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function MaxLength(length: number, message?: string): FormValidatorFn {
|
||||
return (_: string, value: unknown) => {
|
||||
let isValid = true;
|
||||
if (value != null && typeof value === 'string' && value.length >= length)
|
||||
isValid = false;
|
||||
return {
|
||||
isValid,
|
||||
message: message || `Must be less than ${length} characters long`,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function IsRequired(message?: string): FormValidatorFn {
|
||||
return (_: string, value: unknown) => {
|
||||
return {
|
||||
isValid: !!value,
|
||||
message: message || `Field is required`,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function IsEmail(message?: string): FormValidatorFn {
|
||||
return (_: string, value: unknown) => {
|
||||
let isValid = value != null && /\S+@\S+\.\S+/.test(value as string);
|
||||
return {
|
||||
isValid,
|
||||
message: message || `Must be a valid email address`,
|
||||
};
|
||||
};
|
||||
}
|
61
src/components/item/NewStorageModal.vue
Normal file
61
src/components/item/NewStorageModal.vue
Normal file
@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<Modal ref="modalRef" size="md">
|
||||
<template #title>
|
||||
Create a new storage {{ set === true ? 'set' : '' }}
|
||||
</template>
|
||||
|
||||
<template #default="{ closeModal }">
|
||||
<Form @submit="onSubmit" v-model="data" :validators="validators">
|
||||
<FormField name="displayName" label="Display Name" />
|
||||
<FormField name="type" label="Type" />
|
||||
<button type="submit">Submit</button>
|
||||
</Form>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { BuildingListItem } from '../../interfaces/building.interfaces';
|
||||
import { RoomListItem } from '../../interfaces/room.interfaces';
|
||||
import { StorageSetListItem } from '../../interfaces/storage.interfaces';
|
||||
import { FormSubmit } from '../form/form.types';
|
||||
import Form from '../form/Form.vue';
|
||||
import FormField from '../form/FormField.vue';
|
||||
import { IsRequired, MinLength } from '../form/validators';
|
||||
import Modal from '../Modal.vue';
|
||||
|
||||
const modalRef = ref<InstanceType<typeof Modal>>();
|
||||
const room = ref<RoomListItem>();
|
||||
const building = ref<BuildingListItem>();
|
||||
const set = ref<StorageSetListItem | boolean>();
|
||||
const data = ref({
|
||||
displayName: '',
|
||||
});
|
||||
|
||||
const validators = ref([
|
||||
{
|
||||
field: 'displayName',
|
||||
validators: [MinLength(3), IsRequired()],
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
validators: [IsRequired()],
|
||||
},
|
||||
]);
|
||||
|
||||
defineExpose({
|
||||
openModal: (
|
||||
useBuilding: BuildingListItem,
|
||||
useRoom: RoomListItem,
|
||||
useSet?: StorageSetListItem | boolean
|
||||
) => {
|
||||
room.value = useRoom;
|
||||
building.value = useBuilding;
|
||||
set.value = useSet;
|
||||
modalRef.value?.openModal();
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (value: FormSubmit) => console.log(value);
|
||||
</script>
|
129
src/components/item/StoredItemCard.vue
Normal file
129
src/components/item/StoredItemCard.vue
Normal file
@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex space-x-2">
|
||||
<div
|
||||
:class="[
|
||||
imageClasses,
|
||||
'relative mt-2 flex items-center justify-center overflow-hidden rounded-md bg-gray-100 ring-1 ring-black ring-opacity-5',
|
||||
]"
|
||||
>
|
||||
<img
|
||||
class="object-contain"
|
||||
v-if="storedItem.item.image"
|
||||
:src="storedItem.item.image"
|
||||
:alt="storedItem.item.displayName"
|
||||
/>
|
||||
<CubeIcon :class="iconClasses" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span :class="`text-${size} font-bold`">{{
|
||||
storedItem.item.displayName
|
||||
}}</span>
|
||||
<span :class="`mt-0.5 ${fontSize} text-gray-500`"
|
||||
>· Added
|
||||
{{
|
||||
dateToLocaleString(storedItem.acquiredAt || storedItem.createdAt)
|
||||
}}
|
||||
by {{ storedItem.addedBy.name }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
:class="`flex items-center space-x-1 ${fontSize} text-gray-500`"
|
||||
title="Type"
|
||||
>
|
||||
<InformationCircleIcon class="h-4 w-4" />
|
||||
<span>{{ storedItem.item.type }}</span>
|
||||
<span v-if="storedItem.item.consumable">· Consumable</span>
|
||||
<span v-if="storedItem.consumedAt"
|
||||
>· Consumed at {{ dateToLocaleString(storedItem.consumedAt) }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'flex items-center space-x-1',
|
||||
fontSize,
|
||||
expiresSoon ? 'font-bold text-red-600' : '',
|
||||
hasExpired ? 'uppercase' : '',
|
||||
]"
|
||||
v-if="storedItem.expiresAt"
|
||||
>
|
||||
<ExclamationTriangleIcon class="h-4 w-4" />
|
||||
<span
|
||||
>{{ hasExpired ? 'Expired' : 'Expires' }} at
|
||||
{{ dateToLocaleString(storedItem.expiresAt) }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
:class="`flex items-center space-x-1 ${fontSize} text-gray-500`"
|
||||
title="Barcode"
|
||||
v-if="storedItem.item.barcode"
|
||||
>
|
||||
<QrCodeIcon class="h-4 w-4" />
|
||||
<span>{{ storedItem.item.barcode }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CubeIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
QrCodeIcon,
|
||||
} from '@heroicons/vue/24/outline';
|
||||
import { computed } from 'vue';
|
||||
import { StoredItem } from '../../interfaces/storage.interfaces';
|
||||
|
||||
const props = defineProps<{
|
||||
storedItem: StoredItem;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
hideExtras?: boolean;
|
||||
hideCost?: boolean;
|
||||
}>();
|
||||
|
||||
const imageClasses = computed(() => {
|
||||
if (props.size === 'sm') return 'h-8 w-8';
|
||||
if (props.size === 'md') return 'h-12 w-12';
|
||||
return 'h-16 w-16';
|
||||
});
|
||||
|
||||
const iconClasses = computed(() => {
|
||||
if (props.size === 'sm') return 'h-4 w-4';
|
||||
if (props.size === 'md') return 'h-8 w-8';
|
||||
return 'h-12 w-12';
|
||||
});
|
||||
|
||||
const fontSize = computed(() => {
|
||||
if (props.size === 'sm') return 'text-[0.75rem]';
|
||||
return 'text-sm';
|
||||
});
|
||||
|
||||
const expiresSoon = computed(
|
||||
() =>
|
||||
(!!props.storedItem.expiresAt &&
|
||||
!props.storedItem.consumedAt &&
|
||||
new Date(props.storedItem.expiresAt).getTime() <
|
||||
Date.now() + 604800000) ??
|
||||
false
|
||||
);
|
||||
|
||||
const hasExpired = computed(
|
||||
() =>
|
||||
(!!props.storedItem.expiresAt &&
|
||||
!props.storedItem.consumedAt &&
|
||||
new Date(props.storedItem.expiresAt).getTime() < Date.now()) ??
|
||||
false
|
||||
);
|
||||
|
||||
const dateToLocaleString = (dateField: string) => {
|
||||
const converted = new Date(dateField);
|
||||
return `${converted.toLocaleString('en-UK')}`;
|
||||
};
|
||||
</script>
|
@ -70,7 +70,10 @@
|
||||
v-if="!movingBubble || storage.id === movingBubble?.id"
|
||||
:storage="storage"
|
||||
:class="{
|
||||
'z-20': storage.id === hoveredBubble?.id,
|
||||
'z-20':
|
||||
storage.id === hoveredBubble?.id ||
|
||||
storage.id === selectedStorage?.id ||
|
||||
(isSet(storage) && storage.id === selectedSet?.id),
|
||||
'pointer-events-none': !!movingBubble,
|
||||
}"
|
||||
@mouseenter="() => (hoveredBubble = storage)"
|
||||
@ -107,9 +110,19 @@
|
||||
:storages="storages"
|
||||
:selected-room="selectedRoom"
|
||||
:selected-storage="selectedStorage"
|
||||
:selected-set="selectedSet"
|
||||
@select-room="(room) => selectRoomFromList(room)"
|
||||
@select-storage="(storage) => selectStorage(storage)"
|
||||
@new-storage="(set) => addNewStorage(set)"
|
||||
/>
|
||||
|
||||
<StoredItemCard
|
||||
v-for="stored of selectedStorage?.items || []"
|
||||
size="lg"
|
||||
:stored-item="stored"
|
||||
/>
|
||||
|
||||
<NewStorageModal ref="newStorage" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@ -122,7 +135,10 @@ import { computed, ref } from '@vue/reactivity';
|
||||
import HousePlanner from '../../../components/house-planner/HousePlanner.vue';
|
||||
import { boundingBox } from '../../../modules/house-planner/utils';
|
||||
import { Vec2 } from '../../../modules/house-planner/interfaces';
|
||||
import { RoomLayoutObject } from '../../../interfaces/room.interfaces';
|
||||
import {
|
||||
RoomLayoutObject,
|
||||
RoomListItem,
|
||||
} from '../../../interfaces/room.interfaces';
|
||||
import jfetch from '../../../utils/jfetch';
|
||||
import { BACKEND_URL } from '../../../constants';
|
||||
import { useAccessToken } from '../../../composables/useAccessToken';
|
||||
@ -136,6 +152,8 @@ import RoomPolygon from './RoomPolygon.vue';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import isSet from '../../../utils/is-storage-set';
|
||||
import ItemSelector from './ItemSelector.vue';
|
||||
import StoredItemCard from '../../../components/item/StoredItemCard.vue';
|
||||
import NewStorageModal from '../../../components/item/NewStorageModal.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const buildingStore = useBuildingStore();
|
||||
@ -151,6 +169,8 @@ const movingBubble = ref<StorageSharedType>();
|
||||
const selectedSet = ref<StorageSetListItem>();
|
||||
const selectedStorage = ref<StorageListItem>();
|
||||
|
||||
const newStorage = ref<InstanceType<typeof NewStorageModal>>();
|
||||
|
||||
const floor = computed(() =>
|
||||
building.value?.floors.find(
|
||||
(floor) => floor.number === Number(route.params.number)
|
||||
@ -208,6 +228,15 @@ const setBubbleLocation = async (x: number, y: number) => {
|
||||
movingBubble.value = undefined;
|
||||
};
|
||||
|
||||
const addNewStorage = (set: boolean | StorageSetListItem) => {
|
||||
if (!building.value || !selectedRoom.value) return;
|
||||
newStorage.value?.openModal(
|
||||
building.value,
|
||||
selectedRoom.value as unknown as RoomListItem,
|
||||
set
|
||||
);
|
||||
};
|
||||
|
||||
const selectRoomFromList = (room: RoomLayoutObject) => {
|
||||
canvas.value?.setViewRectangle(room?.boundingBox);
|
||||
selectedStorage.value = undefined;
|
||||
|
@ -20,7 +20,8 @@
|
||||
v-for="storage of storages"
|
||||
@click="emit('selectStorage', storage)"
|
||||
:class="[
|
||||
selectedSet?.id === storage.id || selectedStorage?.id === storage.id
|
||||
(isSet(storage) && selectedSet?.id === storage.id) ||
|
||||
selectedStorage?.id === storage.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',
|
||||
@ -38,6 +39,22 @@
|
||||
</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>
|
||||
|
||||
<div class="flex flex-col" v-if="selectedSet">
|
||||
@ -52,6 +69,15 @@
|
||||
<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>
|
||||
|
||||
<div class="flex flex-col" v-if="selectedStorage?.items">
|
||||
@ -65,12 +91,21 @@
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon } from '@heroicons/vue/24/outline';
|
||||
import { ChevronRightIcon, PlusIcon } from '@heroicons/vue/24/outline';
|
||||
import isSet from '../../../utils/is-storage-set';
|
||||
import {
|
||||
StorageListItem,
|
||||
@ -90,5 +125,7 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
(e: 'selectStorage', storage: StorageSharedType): void;
|
||||
(e: 'selectRoom', room: RoomLayoutObject): void;
|
||||
(e: 'newStorage', set: boolean | StorageSetListItem): void;
|
||||
(e: 'addItem', storage: StorageListItem): void;
|
||||
}>();
|
||||
</script>
|
||||
|
Loading…
Reference in New Issue
Block a user