forked from mirrors/homebox
feat: allow nested relationships for locations and items (#102)
Basic implementation that allows organizing Locations and Items within each other.
This commit is contained in:
parent
fe6cd431a6
commit
a4b4fe3454
37 changed files with 2329 additions and 126 deletions
|
@ -3,7 +3,6 @@
|
|||
v-if="to"
|
||||
v-bind="attributes"
|
||||
ref="submitBtn"
|
||||
:to="to"
|
||||
class="btn"
|
||||
:class="{
|
||||
loading: loading,
|
||||
|
@ -64,7 +63,7 @@
|
|||
const attributes = computed(() => {
|
||||
if (props.to) {
|
||||
return {
|
||||
href: props.to,
|
||||
to: props.to,
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
|
149
frontend/components/Form/Autocomplete.vue
Normal file
149
frontend/components/Form/Autocomplete.vue
Normal file
|
@ -0,0 +1,149 @@
|
|||
<template>
|
||||
<div ref="menu" class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">{{ label }}</span>
|
||||
</label>
|
||||
<div class="dropdown dropdown-top sm:dropdown-end">
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="isearch"
|
||||
tabindex="0"
|
||||
class="input w-full items-center flex flex-wrap border border-gray-400 rounded-lg"
|
||||
@keyup.enter="selectFirst"
|
||||
/>
|
||||
<button
|
||||
v-if="!!modelValue && Object.keys(modelValue).length !== 0"
|
||||
style="transform: translateY(-50%)"
|
||||
class="top-1/2 absolute right-2 btn btn-xs btn-circle no-animation"
|
||||
@click="clear"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
style="display: inline"
|
||||
class="dropdown-content mb-1 menu shadow border border-gray-400 rounded bg-base-100 w-full z-[9999] max-h-60 overflow-y-scroll"
|
||||
>
|
||||
<li v-for="(obj, idx) in filtered" :key="idx">
|
||||
<button type="button" @click="select(obj)">
|
||||
{{ usingObjects ? obj[itemText] : obj }}
|
||||
</button>
|
||||
</li>
|
||||
<li class="hidden first:flex">
|
||||
<button disabled>
|
||||
{{ noResultsText }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
type ItemsObject = {
|
||||
text?: string;
|
||||
value?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
modelValue: string | ItemsObject;
|
||||
items: ItemsObject[] | string[];
|
||||
itemText?: keyof ItemsObject;
|
||||
itemValue?: keyof ItemsObject;
|
||||
search?: string;
|
||||
noResultsText?: string;
|
||||
}
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "update:search"]);
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
label: "",
|
||||
modelValue: "",
|
||||
items: () => [],
|
||||
itemText: "text",
|
||||
itemValue: "value",
|
||||
search: "",
|
||||
noResultsText: "No Results Found",
|
||||
});
|
||||
|
||||
function clear() {
|
||||
select(value.value);
|
||||
}
|
||||
|
||||
const isearch = ref("");
|
||||
watch(isearch, () => {
|
||||
internalSearch.value = isearch.value;
|
||||
});
|
||||
|
||||
const internalSearch = useVModel(props, "search", emit);
|
||||
const value = useVModel(props, "modelValue", emit);
|
||||
|
||||
const usingObjects = computed(() => {
|
||||
return props.items.length > 0 && typeof props.items[0] === "object";
|
||||
});
|
||||
|
||||
/**
|
||||
* isStrings is a type guard function to check if the items are an array of string
|
||||
*/
|
||||
function isStrings(_arr: string[] | ItemsObject[]): _arr is string[] {
|
||||
return !usingObjects.value;
|
||||
}
|
||||
|
||||
function selectFirst() {
|
||||
if (filtered.value.length > 0) {
|
||||
select(filtered.value[0]);
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
value,
|
||||
() => {
|
||||
if (value.value) {
|
||||
if (typeof value.value === "string") {
|
||||
isearch.value = value.value;
|
||||
} else {
|
||||
isearch.value = value.value[props.itemText] as string;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
function select(obj: string | ItemsObject) {
|
||||
if (isStrings(props.items)) {
|
||||
if (obj === value.value) {
|
||||
value.value = "";
|
||||
return;
|
||||
}
|
||||
value.value = obj;
|
||||
} else {
|
||||
if (obj === value.value) {
|
||||
value.value = {};
|
||||
return;
|
||||
}
|
||||
|
||||
value.value = obj;
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (!isearch.value || isearch.value === "") {
|
||||
return props.items;
|
||||
}
|
||||
|
||||
if (isStrings(props.items)) {
|
||||
return props.items.filter(item => item.toLowerCase().includes(isearch.value.toLowerCase()));
|
||||
} else {
|
||||
return props.items.filter(item => {
|
||||
if (props.itemText && props.itemText in item) {
|
||||
return (item[props.itemText] as string).toLowerCase().includes(isearch.value.toLowerCase());
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
|
@ -45,6 +45,10 @@
|
|||
type: String,
|
||||
default: "",
|
||||
},
|
||||
compareKey: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedIdx = ref(-1);
|
||||
|
@ -52,36 +56,34 @@
|
|||
const internalSelected = useVModel(props, "modelValue", emit);
|
||||
const internalValue = useVModel(props, "value", emit);
|
||||
|
||||
watch(selectedIdx, newVal => {
|
||||
internalSelected.value = props.items[newVal];
|
||||
});
|
||||
|
||||
watch(selectedIdx, newVal => {
|
||||
if (props.valueKey) {
|
||||
internalValue.value = props.items[newVal][props.valueKey];
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
internalSelected,
|
||||
() => {
|
||||
const idx = props.items.findIndex(item => compare(item, internalSelected.value));
|
||||
selectedIdx.value = idx;
|
||||
selectedIdx,
|
||||
newVal => {
|
||||
if (newVal === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.value) {
|
||||
internalValue.value = props.items[newVal][props.valueKey];
|
||||
}
|
||||
|
||||
internalSelected.value = props.items[newVal];
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
internalValue,
|
||||
[internalSelected, () => props.value],
|
||||
() => {
|
||||
const idx = props.items.findIndex(item => compare(item[props.valueKey], internalValue.value));
|
||||
if (props.valueKey) {
|
||||
const idx = props.items.findIndex(item => compare(item, internalValue.value));
|
||||
selectedIdx.value = idx;
|
||||
return;
|
||||
}
|
||||
const idx = props.items.findIndex(item => compare(item, internalSelected.value));
|
||||
selectedIdx.value = idx;
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
@ -90,8 +92,13 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
if (!a || !b) {
|
||||
return false;
|
||||
if (props.valueKey) {
|
||||
return a[props.valueKey] === b;
|
||||
}
|
||||
|
||||
// Try compare key
|
||||
if (props.compareKey && a && b) {
|
||||
return a[props.compareKey] === b[props.compareKey];
|
||||
}
|
||||
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
|
|
|
@ -17,20 +17,18 @@
|
|||
<Icon name="heroicons-map-pin" class="swap-off" />
|
||||
</label>
|
||||
{{ location.name }}
|
||||
<span v-if="location.itemCount" class="badge badge-secondary badge-lg ml-auto text-secondary-content">
|
||||
{{ location.itemCount }}</span
|
||||
>
|
||||
<span v-if="hasCount" class="badge badge-secondary badge-lg ml-auto text-secondary-content"> {{ count }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { LocationOutCount } from "~~/lib/api/types/data-contracts";
|
||||
import { LocationOut, LocationOutCount, LocationSummary } from "~~/lib/api/types/data-contracts";
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
location: {
|
||||
type: Object as () => LocationOutCount,
|
||||
type: Object as () => LocationOutCount | LocationOut | LocationSummary,
|
||||
required: true,
|
||||
},
|
||||
dense: {
|
||||
|
@ -39,6 +37,16 @@
|
|||
},
|
||||
});
|
||||
|
||||
const hasCount = computed(() => {
|
||||
return !!(props.location as LocationOutCount).itemCount;
|
||||
});
|
||||
|
||||
const count = computed(() => {
|
||||
if (hasCount.value) {
|
||||
return (props.location as LocationOutCount).itemCount;
|
||||
}
|
||||
});
|
||||
|
||||
const card = ref(null);
|
||||
const isHover = useElementHover(card);
|
||||
const { focused } = useFocus(card);
|
||||
|
|
36
frontend/composables/use-item-search.ts
Normal file
36
frontend/composables/use-item-search.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { ItemSummary, LabelSummary, LocationSummary } from "~~/lib/api/types/data-contracts";
|
||||
import { UserClient } from "~~/lib/api/user";
|
||||
|
||||
type SearchOptions = {
|
||||
immediate?: boolean;
|
||||
};
|
||||
|
||||
export function useItemSearch(client: UserClient, opts?: SearchOptions) {
|
||||
const query = ref("");
|
||||
const locations = ref<LocationSummary[]>([]);
|
||||
const labels = ref<LabelSummary[]>([]);
|
||||
const results = ref<ItemSummary[]>([]);
|
||||
|
||||
watchDebounced(query, search, { debounce: 250, maxWait: 1000 });
|
||||
async function search() {
|
||||
const locIds = locations.value.map(l => l.id);
|
||||
const labelIds = labels.value.map(l => l.id);
|
||||
|
||||
const { data, error } = await client.items.getAll({ q: query.value, locations: locIds, labels: labelIds });
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
results.value = data.items;
|
||||
}
|
||||
|
||||
if (opts?.immediate) {
|
||||
search();
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
results,
|
||||
locations,
|
||||
labels,
|
||||
};
|
||||
}
|
|
@ -1,9 +1,7 @@
|
|||
import { BaseAPI, route } from "../base";
|
||||
import { LocationOutCount, LocationCreate, LocationOut } from "../types/data-contracts";
|
||||
import { LocationOutCount, LocationCreate, LocationOut, LocationUpdate } from "../types/data-contracts";
|
||||
import { Results } from "../types/non-generated";
|
||||
|
||||
export type LocationUpdate = LocationCreate;
|
||||
|
||||
export class LocationsApi extends BaseAPI {
|
||||
getAll() {
|
||||
return this.http.get<Results<LocationOutCount>>({ url: route("/locations") });
|
||||
|
|
|
@ -49,6 +49,7 @@ export interface ItemCreate {
|
|||
/** Edges */
|
||||
locationId: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
export interface ItemField {
|
||||
|
@ -63,10 +64,9 @@ export interface ItemField {
|
|||
|
||||
export interface ItemOut {
|
||||
attachments: ItemAttachment[];
|
||||
children: ItemSummary[];
|
||||
createdAt: Date;
|
||||
description: string;
|
||||
|
||||
/** Future */
|
||||
fields: ItemField[];
|
||||
id: string;
|
||||
insured: boolean;
|
||||
|
@ -76,13 +76,14 @@ export interface ItemOut {
|
|||
lifetimeWarranty: boolean;
|
||||
|
||||
/** Edges */
|
||||
location: LocationSummary;
|
||||
location: LocationSummary | null;
|
||||
manufacturer: string;
|
||||
modelNumber: string;
|
||||
name: string;
|
||||
|
||||
/** Extras */
|
||||
notes: string;
|
||||
parent: ItemSummary | null;
|
||||
purchaseFrom: string;
|
||||
|
||||
/** @example 0 */
|
||||
|
@ -113,7 +114,7 @@ export interface ItemSummary {
|
|||
labels: LabelSummary[];
|
||||
|
||||
/** Edges */
|
||||
location: LocationSummary;
|
||||
location: LocationSummary | null;
|
||||
name: string;
|
||||
quantity: number;
|
||||
updatedAt: Date;
|
||||
|
@ -137,6 +138,7 @@ export interface ItemUpdate {
|
|||
|
||||
/** Extras */
|
||||
notes: string;
|
||||
parentId: string | null;
|
||||
purchaseFrom: string;
|
||||
|
||||
/** @example 0 */
|
||||
|
@ -189,11 +191,13 @@ export interface LocationCreate {
|
|||
}
|
||||
|
||||
export interface LocationOut {
|
||||
children: LocationSummary[];
|
||||
createdAt: Date;
|
||||
description: string;
|
||||
id: string;
|
||||
items: ItemSummary[];
|
||||
name: string;
|
||||
parent: LocationSummary;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
|
@ -214,6 +218,13 @@ export interface LocationSummary {
|
|||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface LocationUpdate {
|
||||
description: string;
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
export interface PaginationResultRepoItemSummary {
|
||||
items: ItemSummary[];
|
||||
page: number;
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
import { useLabelStore } from "~~/stores/labels";
|
||||
import { useLocationStore } from "~~/stores/locations";
|
||||
import { capitalize } from "~~/lib/strings";
|
||||
import Autocomplete from "~~/components/Form/Autocomplete.vue";
|
||||
|
||||
definePageMeta({
|
||||
middleware: ["auth"],
|
||||
|
@ -37,8 +38,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
if (data.parent) {
|
||||
parent.value = data.parent;
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
refresh();
|
||||
});
|
||||
|
@ -48,6 +54,7 @@
|
|||
...item.value,
|
||||
locationId: item.value.location?.id,
|
||||
labelIds: item.value.labels.map(l => l.id),
|
||||
parentId: parent.value ? parent.value.id : null,
|
||||
};
|
||||
|
||||
const { error } = await api.items.update(itemId.value, payload);
|
||||
|
@ -256,7 +263,6 @@
|
|||
|
||||
async function updateAttachment() {
|
||||
editState.loading = true;
|
||||
console.log(editState.type);
|
||||
const { error, data } = await api.items.updateAttachment(itemId.value, editState.id, {
|
||||
title: editState.title,
|
||||
type: editState.type,
|
||||
|
@ -306,6 +312,9 @@
|
|||
timeValue: null,
|
||||
});
|
||||
}
|
||||
|
||||
const { query, results } = useItemSearch(api, { immediate: false });
|
||||
const parent = ref();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -314,8 +323,8 @@
|
|||
<template #title> Attachment Edit </template>
|
||||
|
||||
<FormTextField v-model="editState.title" label="Attachment Title" />
|
||||
{{ editState.type }}
|
||||
<FormSelect
|
||||
v-model="editState.obj"
|
||||
v-model:value="editState.type"
|
||||
label="Attachment Type"
|
||||
value-key="value"
|
||||
|
@ -354,8 +363,24 @@
|
|||
</template>
|
||||
</BaseSectionHeader>
|
||||
<div class="px-5 mb-6 grid md:grid-cols-2 gap-4">
|
||||
<FormSelect v-if="item" v-model="item.location" label="Location" :items="locations ?? []" />
|
||||
<FormSelect
|
||||
v-if="item"
|
||||
v-model="item.location"
|
||||
label="Location"
|
||||
:items="locations ?? []"
|
||||
compare-key="id"
|
||||
/>
|
||||
<FormMultiselect v-model="item.labels" label="Labels" :items="labels ?? []" />
|
||||
|
||||
<Autocomplete
|
||||
v-if="!preferences.editorSimpleView"
|
||||
v-model="parent"
|
||||
v-model:search="query"
|
||||
:items="results"
|
||||
item-text="name"
|
||||
label="Parent Item"
|
||||
no-results-text="Type to search..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-300 sm:p-0">
|
||||
|
|
|
@ -76,6 +76,10 @@
|
|||
name: "Description",
|
||||
text: item.value?.description,
|
||||
},
|
||||
{
|
||||
name: "Quantity",
|
||||
text: item.value?.quantity,
|
||||
},
|
||||
{
|
||||
name: "Serial Number",
|
||||
text: item.value?.serialNumber,
|
||||
|
@ -287,12 +291,23 @@
|
|||
<span class="text-base-content">
|
||||
{{ item ? item.name : "" }}
|
||||
</span>
|
||||
<div v-if="item.parent" class="text-sm breadcrumbs pb-0">
|
||||
<ul class="text-base-content/70">
|
||||
<li>
|
||||
<NuxtLink :to="`/item/${item.parent.id}`"> {{ item.parent.name }}</NuxtLink>
|
||||
</li>
|
||||
<li>{{ item.name }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<template #description>
|
||||
<p class="text-sm text-base-content font-bold pb-0 mb-0">
|
||||
{{ item.location.name }} - Quantity {{ item.quantity }}
|
||||
</p>
|
||||
<div v-if="item.labels && item.labels.length > 0" class="flex flex-wrap gap-3 mt-3">
|
||||
<LabelChip v-for="label in item.labels" :key="label.id" class="badge-primary" :label="label" />
|
||||
<div class="flex flex-wrap gap-2 mt-3">
|
||||
<NuxtLink ref="badge" class="badge p-3" :to="`/location/${item.location.id}`">
|
||||
<Icon name="heroicons-map-pin" class="mr-2 swap-on"></Icon>
|
||||
{{ item.location.name }}
|
||||
</NuxtLink>
|
||||
<template v-if="item.labels && item.labels.length > 0">
|
||||
<LabelChip v-for="label in item.labels" :key="label.id" class="badge-primary" :label="label" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</BaseSectionHeader>
|
||||
|
@ -378,5 +393,12 @@
|
|||
</BaseCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="my-6 px-3">
|
||||
<BaseSectionHeader v-if="item && item.children && item.children.length > 0"> Child Items </BaseSectionHeader>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<ItemCard v-for="child in item.children" :key="child.id" :item="child" />
|
||||
</div>
|
||||
</section>
|
||||
</BaseContainer>
|
||||
</template>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { Detail, CustomDetail } from "~~/components/global/DetailsSection/types";
|
||||
import { LocationSummary, LocationUpdate } from "~~/lib/api/types/data-contracts";
|
||||
import { useLocationStore } from "~~/stores/locations";
|
||||
|
||||
definePageMeta({
|
||||
middleware: ["auth"],
|
||||
|
@ -20,6 +22,11 @@
|
|||
navigateTo("/home");
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.parent) {
|
||||
parent.value = locations.value.find(l => l.id === data.parent.id);
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
|
@ -62,7 +69,7 @@
|
|||
|
||||
async function confirmDelete() {
|
||||
const { isCanceled } = await confirm.open(
|
||||
"Are you sure you want to delete this location? This action cannot be undone."
|
||||
"Are you sure you want to delete this location and all of its items? This action cannot be undone."
|
||||
);
|
||||
if (isCanceled) {
|
||||
return;
|
||||
|
@ -80,9 +87,11 @@
|
|||
|
||||
const updateModal = ref(false);
|
||||
const updating = ref(false);
|
||||
const updateData = reactive({
|
||||
const updateData = reactive<LocationUpdate>({
|
||||
id: locationId.value,
|
||||
name: "",
|
||||
description: "",
|
||||
parentId: null,
|
||||
});
|
||||
|
||||
function openUpdate() {
|
||||
|
@ -93,6 +102,7 @@
|
|||
|
||||
async function update() {
|
||||
updating.value = true;
|
||||
updateData.parentId = parent.value?.id || null;
|
||||
const { error, data } = await api.locations.update(locationId.value, updateData);
|
||||
|
||||
if (error) {
|
||||
|
@ -105,6 +115,12 @@
|
|||
updateModal.value = false;
|
||||
updating.value = false;
|
||||
}
|
||||
|
||||
const locationStore = useLocationStore();
|
||||
const locations = computed(() => locationStore.locations);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const parent = ref<LocationSummary | any>({});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -114,6 +130,7 @@
|
|||
<form v-if="location" @submit.prevent="update">
|
||||
<FormTextField v-model="updateData.name" :autofocus="true" label="Location Name" />
|
||||
<FormTextArea v-model="updateData.description" label="Location Description" />
|
||||
<FormAutocomplete v-model="parent" :items="locations" item-text="name" item-value="id" label="Parent" />
|
||||
<div class="modal-action">
|
||||
<BaseButton type="submit" :loading="updating"> Update </BaseButton>
|
||||
</div>
|
||||
|
@ -127,6 +144,14 @@
|
|||
<span class="text-base-content">
|
||||
{{ location ? location.name : "" }}
|
||||
</span>
|
||||
<div v-if="location && location.parent" class="text-sm breadcrumbs pb-0">
|
||||
<ul class="text-base-content/70">
|
||||
<li>
|
||||
<NuxtLink :to="`/location/${location.parent.id}`"> {{ location.parent.name }}</NuxtLink>
|
||||
</li>
|
||||
<li>{{ location.name }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</BaseSectionHeader>
|
||||
</template>
|
||||
|
||||
|
@ -152,11 +177,18 @@
|
|||
<DetailsSection :details="details" />
|
||||
</BaseCard>
|
||||
|
||||
<section v-if="location">
|
||||
<section v-if="location && location.items.length > 0">
|
||||
<BaseSectionHeader class="mb-5"> Items </BaseSectionHeader>
|
||||
<div class="grid gap-2 grid-cols-1 sm:grid-cols-2">
|
||||
<ItemCard v-for="item in location.items" :key="item.id" :item="item" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="location && location.children.length > 0">
|
||||
<BaseSectionHeader class="mb-5"> Child Locations </BaseSectionHeader>
|
||||
<div class="grid gap-2 grid-cols-1 sm:grid-cols-3">
|
||||
<LocationCard v-for="item in location.children" :key="item.id" :location="item" />
|
||||
</div>
|
||||
</section>
|
||||
</BaseContainer>
|
||||
</template>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue