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"
|
v-if="!movingBubble || storage.id === movingBubble?.id"
|
||||||
:storage="storage"
|
:storage="storage"
|
||||||
:class="{
|
: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,
|
'pointer-events-none': !!movingBubble,
|
||||||
}"
|
}"
|
||||||
@mouseenter="() => (hoveredBubble = storage)"
|
@mouseenter="() => (hoveredBubble = storage)"
|
||||||
@ -107,9 +110,19 @@
|
|||||||
:storages="storages"
|
:storages="storages"
|
||||||
:selected-room="selectedRoom"
|
:selected-room="selectedRoom"
|
||||||
:selected-storage="selectedStorage"
|
:selected-storage="selectedStorage"
|
||||||
|
:selected-set="selectedSet"
|
||||||
@select-room="(room) => selectRoomFromList(room)"
|
@select-room="(room) => selectRoomFromList(room)"
|
||||||
@select-storage="(storage) => selectStorage(storage)"
|
@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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@ -122,7 +135,10 @@ import { computed, ref } from '@vue/reactivity';
|
|||||||
import HousePlanner from '../../../components/house-planner/HousePlanner.vue';
|
import HousePlanner from '../../../components/house-planner/HousePlanner.vue';
|
||||||
import { boundingBox } from '../../../modules/house-planner/utils';
|
import { boundingBox } from '../../../modules/house-planner/utils';
|
||||||
import { Vec2 } from '../../../modules/house-planner/interfaces';
|
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 jfetch from '../../../utils/jfetch';
|
||||||
import { BACKEND_URL } from '../../../constants';
|
import { BACKEND_URL } from '../../../constants';
|
||||||
import { useAccessToken } from '../../../composables/useAccessToken';
|
import { useAccessToken } from '../../../composables/useAccessToken';
|
||||||
@ -136,6 +152,8 @@ 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 './ItemSelector.vue';
|
||||||
|
import StoredItemCard from '../../../components/item/StoredItemCard.vue';
|
||||||
|
import NewStorageModal from '../../../components/item/NewStorageModal.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const buildingStore = useBuildingStore();
|
const buildingStore = useBuildingStore();
|
||||||
@ -151,6 +169,8 @@ const movingBubble = ref<StorageSharedType>();
|
|||||||
const selectedSet = ref<StorageSetListItem>();
|
const selectedSet = ref<StorageSetListItem>();
|
||||||
const selectedStorage = ref<StorageListItem>();
|
const selectedStorage = ref<StorageListItem>();
|
||||||
|
|
||||||
|
const newStorage = ref<InstanceType<typeof NewStorageModal>>();
|
||||||
|
|
||||||
const floor = computed(() =>
|
const floor = computed(() =>
|
||||||
building.value?.floors.find(
|
building.value?.floors.find(
|
||||||
(floor) => floor.number === Number(route.params.number)
|
(floor) => floor.number === Number(route.params.number)
|
||||||
@ -208,6 +228,15 @@ const setBubbleLocation = async (x: number, y: number) => {
|
|||||||
movingBubble.value = undefined;
|
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) => {
|
const selectRoomFromList = (room: RoomLayoutObject) => {
|
||||||
canvas.value?.setViewRectangle(room?.boundingBox);
|
canvas.value?.setViewRectangle(room?.boundingBox);
|
||||||
selectedStorage.value = undefined;
|
selectedStorage.value = undefined;
|
||||||
|
@ -20,7 +20,8 @@
|
|||||||
v-for="storage of storages"
|
v-for="storage of storages"
|
||||||
@click="emit('selectStorage', storage)"
|
@click="emit('selectStorage', storage)"
|
||||||
:class="[
|
: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'
|
? 'bg-blue-100 hover:bg-blue-200'
|
||||||
: 'hover:bg-blue-100',
|
: 'hover:bg-blue-100',
|
||||||
'flex items-center justify-between border-b-2 border-gray-100 py-2 px-2',
|
'flex items-center justify-between border-b-2 border-gray-100 py-2 px-2',
|
||||||
@ -38,6 +39,22 @@
|
|||||||
</span>
|
</span>
|
||||||
<ChevronRightIcon class="h-4 w-4" />
|
<ChevronRightIcon class="h-4 w-4" />
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div class="flex flex-col" v-if="selectedSet">
|
<div class="flex flex-col" v-if="selectedSet">
|
||||||
@ -52,6 +69,15 @@
|
|||||||
<span>{{ storage.displayName }} ({{ storage.itemCount }})</span>
|
<span>{{ storage.displayName }} ({{ storage.itemCount }})</span>
|
||||||
<ChevronRightIcon class="h-4 w-4" />
|
<ChevronRightIcon class="h-4 w-4" />
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div class="flex flex-col" v-if="selectedStorage?.items">
|
<div class="flex flex-col" v-if="selectedStorage?.items">
|
||||||
@ -65,12 +91,21 @@
|
|||||||
<span>{{ item.item.displayName }}</span>
|
<span>{{ item.item.displayName }}</span>
|
||||||
<ChevronRightIcon class="h-4 w-4" />
|
<ChevronRightIcon class="h-4 w-4" />
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 isSet from '../../../utils/is-storage-set';
|
||||||
import {
|
import {
|
||||||
StorageListItem,
|
StorageListItem,
|
||||||
@ -90,5 +125,7 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'selectStorage', storage: StorageSharedType): void;
|
(e: 'selectStorage', storage: StorageSharedType): void;
|
||||||
(e: 'selectRoom', room: RoomLayoutObject): void;
|
(e: 'selectRoom', room: RoomLayoutObject): void;
|
||||||
|
(e: 'newStorage', set: boolean | StorageSetListItem): void;
|
||||||
|
(e: 'addItem', storage: StorageListItem): void;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
Loading…
Reference in New Issue
Block a user