mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-21 13:23:14 +01:00
Use Tanstack table for Selectable Table, quick actions (#998)
* feat: implement example of data table * feat: load item data into table * chore: begin switching dialogs * feat: implement old dialog for controlling headers and page size * feat: get table into relatively usable state * feat: enhance dropdown actions for multi-selection and CSV download * feat: enhance table cell and dropdown button styles for better usability * feat: json download for table * feat: add expanded row component for item details in data table * chore: add translation support * feat: restore table on home page * fix: oops need ids * feat: move card view to use tanstack to allow for pagination * feat: switch the items search to use ItemViewSelectable * fix: update pagination handling and improve button click logic * feat: improve selectable table * feat: add indeterminate to checkbox * feat: overhaul maintenance dialog to use new system and add maintenance options to table * feat: add label ids and location id to item patch api * feat: change location and labels in table view * feat: add quick actions preference and enable toggle in table settings * fix: lint * fix: remove sized 1 pages * fix: attempt to fix type error * fix: various issues * fix: remove * fix: refactor item fetching logic to use useAsyncData for improved reactivity and improve use confirm * fix: sort backend issues * fix: enhance CSV export functionality by escaping fields to prevent formula injection * fix: put aria sort on th not button * chore: update api types
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
<CardTitle class="flex items-center">
|
||||
<slot />
|
||||
</CardTitle>
|
||||
<slot name="subtitle" />
|
||||
<CardDescription v-if="$slots.description">
|
||||
<slot name="description" />
|
||||
</CardDescription>
|
||||
|
||||
@@ -115,7 +115,6 @@
|
||||
import MdiAlertCircleOutline from "~icons/mdi/alert-circle-outline";
|
||||
import MdiBarcode from "~icons/mdi/barcode";
|
||||
import MdiLoading from "~icons/mdi/loading";
|
||||
import type { TableData } from "~/components/Item/View/Table.types";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -225,7 +224,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
function extractValue(data: TableData, value: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractValue(data: Record<string, any>, value: string) {
|
||||
const parts = value.split(".");
|
||||
let current = data;
|
||||
for (const part of parts) {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<Card class="relative overflow-hidden">
|
||||
<div v-if="tableRow" class="absolute left-1 top-1 z-10">
|
||||
<Checkbox
|
||||
class="size-5 bg-accent hover:bg-background-accent"
|
||||
:model-value="tableRow.getIsSelected()"
|
||||
:aria-label="$t('components.item.view.selectable.select_card')"
|
||||
@update:model-value="tableRow.toggleSelected()"
|
||||
/>
|
||||
</div>
|
||||
<NuxtLink :to="`/item/${item.id}`">
|
||||
<div class="relative h-[200px]">
|
||||
<img v-if="imageUrl" class="h-[200px] w-full object-cover shadow-md" loading="lazy" :src="imageUrl" alt="" />
|
||||
@@ -64,6 +72,8 @@
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import Markdown from "@/components/global/Markdown.vue";
|
||||
import LabelChip from "@/components/Label/Chip.vue";
|
||||
import type { Row } from "@tanstack/vue-table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
const api = useUserApi();
|
||||
|
||||
@@ -92,6 +102,11 @@
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
tableRow: {
|
||||
type: Object as () => Row<ItemSummary>,
|
||||
required: false,
|
||||
default: () => null,
|
||||
},
|
||||
});
|
||||
|
||||
const locationString = computed(
|
||||
|
||||
169
frontend/components/Item/View/ItemChangeDetails.vue
Normal file
169
frontend/components/Item/View/ItemChangeDetails.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import { Dialog, DialogContent, DialogFooter, DialogTitle, DialogHeader } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||
import type { ItemPatch, ItemSummary, LabelOut, LocationSummary } from "~/lib/api/types/data-contracts";
|
||||
import LocationSelector from "~/components/Location/Selector.vue";
|
||||
import MdiLoading from "~icons/mdi/loading";
|
||||
import { toast } from "~/components/ui/sonner";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import LabelSelector from "~/components/Label/Selector.vue";
|
||||
|
||||
const { closeDialog, registerOpenDialogCallback } = useDialog();
|
||||
|
||||
const api = useUserApi();
|
||||
const { t } = useI18n();
|
||||
const labelStore = useLabelStore();
|
||||
|
||||
const allLabels = computed(() => labelStore.labels);
|
||||
|
||||
const items = ref<ItemSummary[]>([]);
|
||||
const saving = ref(false);
|
||||
|
||||
const enabled = reactive({
|
||||
changeLocation: false,
|
||||
addLabels: false,
|
||||
removeLabels: false,
|
||||
});
|
||||
|
||||
const newLocation = ref<LocationSummary | null>(null);
|
||||
const addLabels = ref<string[]>([]);
|
||||
const removeLabels = ref<string[]>([]);
|
||||
|
||||
const availableToAddLabels = ref<LabelOut[]>([]);
|
||||
const availableToRemoveLabels = ref<LabelOut[]>([]);
|
||||
|
||||
const intersectLabelIds = (items: ItemSummary[]): string[] => {
|
||||
if (items.length === 0) return [];
|
||||
const counts = new Map<string, number>();
|
||||
for (const it of items) {
|
||||
const seen = new Set<string>();
|
||||
for (const l of it.labels || []) seen.add(l.id);
|
||||
for (const id of seen) counts.set(id, (counts.get(id) || 0) + 1);
|
||||
}
|
||||
return [...counts.entries()].filter(([_, c]) => c === items.length).map(([id]) => id);
|
||||
};
|
||||
|
||||
const unionLabelIds = (items: ItemSummary[]): string[] => {
|
||||
const s = new Set<string>();
|
||||
for (const it of items) for (const l of it.labels || []) s.add(l.id);
|
||||
return Array.from(s);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const cleanup = registerOpenDialogCallback(DialogID.ItemChangeDetails, params => {
|
||||
items.value = params.items;
|
||||
enabled.changeLocation = params.changeLocation ?? false;
|
||||
enabled.addLabels = params.addLabels ?? false;
|
||||
enabled.removeLabels = params.removeLabels ?? false;
|
||||
|
||||
if (params.changeLocation && params.items.length > 0) {
|
||||
// if all locations are the same then set the current location to said location
|
||||
if (
|
||||
params.items[0]!.location &&
|
||||
params.items.every(item => item.location?.id === params.items[0]!.location?.id)
|
||||
) {
|
||||
newLocation.value = params.items[0]!.location;
|
||||
}
|
||||
}
|
||||
|
||||
if (params.addLabels && params.items.length > 0) {
|
||||
const intersection = intersectLabelIds(params.items);
|
||||
availableToAddLabels.value = allLabels.value.filter(l => !intersection.includes(l.id));
|
||||
}
|
||||
|
||||
if (params.removeLabels && params.items.length > 0) {
|
||||
const union = unionLabelIds(params.items);
|
||||
availableToRemoveLabels.value = allLabels.value.filter(l => union.includes(l.id));
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(cleanup);
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
const location = newLocation.value;
|
||||
const labelsToAdd = addLabels.value;
|
||||
const labelsToRemove = removeLabels.value;
|
||||
if (!items.value.length || (enabled.changeLocation && !location)) {
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
|
||||
await Promise.allSettled(
|
||||
items.value.map(async item => {
|
||||
const patch: ItemPatch = {
|
||||
id: item.id,
|
||||
};
|
||||
|
||||
if (enabled.changeLocation) {
|
||||
patch.locationId = location!.id;
|
||||
}
|
||||
|
||||
let currentLabels = item.labels.map(l => l.id);
|
||||
|
||||
if (enabled.addLabels) {
|
||||
currentLabels = currentLabels.concat(labelsToAdd);
|
||||
}
|
||||
|
||||
if (enabled.removeLabels) {
|
||||
currentLabels = currentLabels.filter(l => !labelsToRemove.includes(l));
|
||||
}
|
||||
|
||||
if (enabled.addLabels || enabled.removeLabels) {
|
||||
patch.labelIds = Array.from(new Set(currentLabels));
|
||||
}
|
||||
|
||||
const { error, data } = await api.items.patch(item.id, patch);
|
||||
|
||||
if (error) {
|
||||
console.error("failed to update item", item.id, data);
|
||||
toast.error(t("components.item.view.change_details.failed_to_update_item"));
|
||||
return;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
closeDialog(DialogID.ItemChangeDetails, true);
|
||||
enabled.changeLocation = false;
|
||||
enabled.addLabels = false;
|
||||
enabled.removeLabels = false;
|
||||
items.value = [];
|
||||
addLabels.value = [];
|
||||
removeLabels.value = [];
|
||||
availableToAddLabels.value = [];
|
||||
availableToRemoveLabels.value = [];
|
||||
saving.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :dialog-id="DialogID.ItemChangeDetails">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t("components.item.view.change_details.title") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<LocationSelector v-if="enabled.changeLocation" v-model="newLocation" />
|
||||
<LabelSelector
|
||||
v-if="enabled.addLabels"
|
||||
v-model="addLabels"
|
||||
:labels="availableToAddLabels"
|
||||
:name="$t('components.item.view.change_details.add_labels')"
|
||||
/>
|
||||
<LabelSelector
|
||||
v-if="enabled.removeLabels"
|
||||
v-model="removeLabels"
|
||||
:labels="availableToRemoveLabels"
|
||||
:name="$t('components.item.view.change_details.remove_labels')"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="submit" :disabled="saving || (enabled.changeLocation && !newLocation)" @click="save">
|
||||
<span v-if="!saving">{{ $t("global.save") }}</span>
|
||||
<MdiLoading v-else class="animate-spin" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -6,17 +6,32 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button, ButtonGroup } from "@/components/ui/button";
|
||||
import BaseSectionHeader from "@/components/Base/SectionHeader.vue";
|
||||
import ItemCard from "@/components/Item/Card.vue";
|
||||
import ItemViewTable from "@/components/Item/View/Table.vue";
|
||||
import DataTable from "./table/data-table.vue";
|
||||
import { makeColumns } from "./table/columns";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { Pagination } from "./pagination";
|
||||
import MaintenanceEditModal from "@/components/Maintenance/EditModal.vue";
|
||||
import ItemChangeDetails from "./ItemChangeDetails.vue";
|
||||
|
||||
type Props = {
|
||||
const props = defineProps<{
|
||||
view?: ViewType;
|
||||
items: ItemSummary[];
|
||||
};
|
||||
locationFlatTree?: FlatTreeItem[];
|
||||
pagination?: Pagination;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "refresh"): void;
|
||||
}>();
|
||||
|
||||
const preferences = useViewPreferences();
|
||||
const { t } = useI18n();
|
||||
const columns = computed(() =>
|
||||
makeColumns(t, () => {
|
||||
emit("refresh");
|
||||
})
|
||||
);
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const viewSet = computed(() => {
|
||||
return !!props.view;
|
||||
});
|
||||
@@ -28,17 +43,29 @@
|
||||
function setViewPreference(view: ViewType) {
|
||||
preferences.value.itemDisplayView = view;
|
||||
}
|
||||
|
||||
const externalPagination = computed(() => !!props.pagination);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<BaseSectionHeader class="mb-2 mt-4 flex items-center justify-between">
|
||||
<MaintenanceEditModal />
|
||||
<ItemChangeDetails />
|
||||
|
||||
<BaseSectionHeader class="flex items-center justify-between" :class="{ 'mb-2 mt-4': !externalPagination }">
|
||||
<div class="flex gap-2 text-nowrap">
|
||||
{{ $t("components.item.view.selectable.items") }}
|
||||
<Badge>
|
||||
<Badge v-if="!externalPagination">
|
||||
{{ items.length }}
|
||||
</Badge>
|
||||
</div>
|
||||
<template #subtitle>
|
||||
<div
|
||||
id="selectable-subtitle"
|
||||
class="flex grow items-center px-2"
|
||||
:class="{ hidden: !preferences.quickActions.enabled }"
|
||||
/>
|
||||
</template>
|
||||
<template #description>
|
||||
<div v-if="!viewSet">
|
||||
<ButtonGroup>
|
||||
@@ -59,15 +86,26 @@
|
||||
</template>
|
||||
</BaseSectionHeader>
|
||||
|
||||
<template v-if="itemView === 'table'">
|
||||
<ItemViewTable :items="items" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
<ItemCard v-for="item in items" :key="item.id" :item="item" />
|
||||
<div class="hidden first:block">{{ $t("components.item.view.selectable.no_items") }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="externalPagination && pagination!.totalSize > 0" class="mb-4 flex items-center text-base font-medium">
|
||||
{{ $t("items.results", { total: pagination!.totalSize }) }}
|
||||
<span class="ml-auto text-base">
|
||||
{{
|
||||
$t("items.pages", {
|
||||
page: pagination!.page,
|
||||
totalPages: Math.ceil(pagination!.totalSize / pagination!.pageSize),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<DataTable
|
||||
:view="itemView"
|
||||
:columns="preferences.quickActions.enabled ? columns : columns.filter(c => c.enableHiding !== false)"
|
||||
:data="items"
|
||||
:location-flat-tree="locationFlatTree"
|
||||
:external-pagination="pagination"
|
||||
@refresh="$emit('refresh')"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { ItemSummary } from "~~/lib/api/types/data-contracts";
|
||||
|
||||
export type TableHeaderType = {
|
||||
text: string;
|
||||
value: keyof ItemSummary;
|
||||
sortable?: boolean;
|
||||
align?: "left" | "center" | "right";
|
||||
enabled: boolean;
|
||||
type?: "price" | "boolean" | "name" | "location" | "date";
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type TableData = Record<string, any>;
|
||||
@@ -1,333 +1,19 @@
|
||||
<template>
|
||||
<Dialog :dialog-id="DialogID.ItemTableSettings">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t("components.item.view.table.table_settings") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div>{{ $t("components.item.view.table.headers") }}</div>
|
||||
<div class="flex flex-col">
|
||||
<div v-for="(h, i) in headers" :key="h.value" class="flex flex-row items-center gap-1">
|
||||
<Button size="icon" class="size-6" variant="ghost" :disabled="i === 0" @click="moveHeader(i, i - 1)">
|
||||
<MdiArrowUp />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
class="size-6"
|
||||
variant="ghost"
|
||||
:disabled="i === headers.length - 1"
|
||||
@click="moveHeader(i, i + 1)"
|
||||
>
|
||||
<MdiArrowDown />
|
||||
</Button>
|
||||
<Checkbox :id="h.value" :model-value="h.enabled" @update:model-value="toggleHeader(h.value)" />
|
||||
<label class="text-sm" :for="h.value"> {{ $t(h.text) }} </label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label> {{ $t("components.item.view.table.rows_per_page") }} </Label>
|
||||
<Select :model-value="pagination.rowsPerPage" @update:model-value="pagination.rowsPerPage = Number($event)">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="10">10</SelectItem>
|
||||
<SelectItem :value="25">25</SelectItem>
|
||||
<SelectItem :value="50">50</SelectItem>
|
||||
<SelectItem :value="100">100</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button @click="closeDialog(DialogID.ItemTableSettings)"> {{ $t("global.save") }} </Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<BaseCard>
|
||||
<Table class="w-full">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead
|
||||
v-for="h in headers.filter(h => h.enabled)"
|
||||
:key="h.value"
|
||||
class="text-no-transform cursor-pointer bg-secondary text-sm text-secondary-foreground hover:bg-secondary/90"
|
||||
@click="sortBy(h.value)"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1"
|
||||
:class="{
|
||||
'justify-center': h.align === 'center',
|
||||
'justify-start': h.align === 'right',
|
||||
'justify-end': h.align === 'left',
|
||||
}"
|
||||
>
|
||||
<template v-if="typeof h === 'string'">{{ h }}</template>
|
||||
<template v-else>{{ $t(h.text) }}</template>
|
||||
<div
|
||||
:data-swap="pagination.descending"
|
||||
:class="{ 'opacity-0': sortByProperty !== h.value }"
|
||||
class="transition-transform duration-300 data-[swap=true]:rotate-180"
|
||||
>
|
||||
<MdiArrowUp class="size-5" />
|
||||
</div>
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="(d, i) in data" :key="d.id" class="relative cursor-pointer">
|
||||
<TableCell
|
||||
v-for="h in headers.filter(h => h.enabled)"
|
||||
:key="`${h.value}-${i}`"
|
||||
:class="{
|
||||
'text-center': h.align === 'center',
|
||||
'text-right': h.align === 'right',
|
||||
'text-left': h.align === 'left',
|
||||
}"
|
||||
>
|
||||
<template v-if="h.type === 'name'">
|
||||
{{ d.name }}
|
||||
</template>
|
||||
<template v-else-if="h.type === 'price'">
|
||||
<Currency :amount="d.purchasePrice" />
|
||||
</template>
|
||||
<template v-else-if="h.type === 'boolean'">
|
||||
<MdiCheck v-if="d.insured" class="inline size-5 text-green-500" />
|
||||
<MdiClose v-else class="inline size-5 text-destructive" />
|
||||
</template>
|
||||
<template v-else-if="h.type === 'location'">
|
||||
<NuxtLink v-if="d.location" class="hover:underline" :to="`/location/${d.location.id}`">
|
||||
{{ d.location.name }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template v-else-if="h.type === 'date'">
|
||||
<DateTime :date="d[h.value]" datetime-type="date" />
|
||||
</template>
|
||||
<slot v-else :name="cell(h)" v-bind="{ item: d }">
|
||||
{{ extractValue(d, h.value) }}
|
||||
</slot>
|
||||
</TableCell>
|
||||
<TableCell class="absolute inset-0">
|
||||
<NuxtLink :to="`/item/${d.id}`" class="absolute inset-0">
|
||||
<span class="sr-only">{{ $t("components.item.view.table.view_item") }}</span>
|
||||
</NuxtLink>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 border-t p-3"
|
||||
:class="{
|
||||
hidden: disableControls,
|
||||
}"
|
||||
>
|
||||
<Button class="size-10 p-0" variant="outline" @click="openDialog(DialogID.ItemTableSettings)">
|
||||
<MdiTableCog />
|
||||
</Button>
|
||||
<Pagination
|
||||
v-slot="{ page }"
|
||||
:items-per-page="pagination.rowsPerPage"
|
||||
:total="props.items.length"
|
||||
:sibling-count="2"
|
||||
@update:page="pagination.page = $event"
|
||||
>
|
||||
<PaginationList v-slot="{ items: pageItems }" class="flex items-center gap-1">
|
||||
<PaginationFirst />
|
||||
<template v-for="(item, index) in pageItems">
|
||||
<PaginationListItem v-if="item.type === 'page'" :key="index" :value="item.value" as-child>
|
||||
<Button class="size-10 p-0" :variant="item.value === page ? 'default' : 'outline'">
|
||||
{{ item.value }}
|
||||
</Button>
|
||||
</PaginationListItem>
|
||||
<PaginationEllipsis v-else :key="item.type" :index="index" />
|
||||
</template>
|
||||
<PaginationLast />
|
||||
</PaginationList>
|
||||
</Pagination>
|
||||
<Button class="invisible hidden size-10 p-0 md:block">
|
||||
<!-- properly centre the pagination buttons -->
|
||||
</Button>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TableData, TableHeaderType } from "./Table.types";
|
||||
import type { ItemSummary } from "~~/lib/api/types/data-contracts";
|
||||
import MdiArrowDown from "~icons/mdi/arrow-down";
|
||||
import MdiArrowUp from "~icons/mdi/arrow-up";
|
||||
import MdiCheck from "~icons/mdi/check";
|
||||
import MdiClose from "~icons/mdi/close";
|
||||
import MdiTableCog from "~icons/mdi/table-cog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationEllipsis,
|
||||
PaginationFirst,
|
||||
PaginationLast,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
} from "@/components/ui/pagination";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import BaseCard from "@/components/Base/Card.vue";
|
||||
import Currency from "~/components/global/Currency.vue";
|
||||
import DateTime from "~/components/global/DateTime.vue";
|
||||
import { computed } from "vue";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
import DataTable from "./table/data-table.vue";
|
||||
import { makeColumns } from "./table/columns";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
|
||||
type Props = {
|
||||
defineProps<{
|
||||
items: ItemSummary[];
|
||||
disableControls?: boolean;
|
||||
};
|
||||
const props = defineProps<Props>();
|
||||
}>();
|
||||
|
||||
const sortByProperty = ref<keyof ItemSummary | "">("");
|
||||
const { t } = useI18n();
|
||||
|
||||
const preferences = useViewPreferences();
|
||||
|
||||
const defaultHeaders = [
|
||||
{ text: "items.asset_id", value: "assetId", enabled: false },
|
||||
{
|
||||
text: "items.name",
|
||||
value: "name",
|
||||
enabled: true,
|
||||
type: "name",
|
||||
},
|
||||
{ text: "items.quantity", value: "quantity", align: "center", enabled: true },
|
||||
{ text: "items.insured", value: "insured", align: "center", enabled: true, type: "boolean" },
|
||||
{ text: "items.purchase_price", value: "purchasePrice", align: "center", enabled: true, type: "price" },
|
||||
{ text: "items.location", value: "location", align: "center", enabled: false, type: "location" },
|
||||
{ text: "items.archived", value: "archived", align: "center", enabled: false, type: "boolean" },
|
||||
{ text: "items.created_at", value: "createdAt", align: "center", enabled: false, type: "date" },
|
||||
{ text: "items.updated_at", value: "updatedAt", align: "center", enabled: false, type: "date" },
|
||||
] satisfies TableHeaderType[];
|
||||
|
||||
const headers = ref<TableHeaderType[]>(
|
||||
(preferences.value.tableHeaders ?? [])
|
||||
.concat(defaultHeaders.filter(h => !preferences.value.tableHeaders?.find(h2 => h2.value === h.value)))
|
||||
// this is a hack to make sure that any changes to the defaultHeaders are reflected in the preferences
|
||||
.map(h => ({
|
||||
...(defaultHeaders.find(h2 => h2.value === h.value) as TableHeaderType),
|
||||
enabled: h.enabled,
|
||||
}))
|
||||
);
|
||||
|
||||
const toggleHeader = (value: string) => {
|
||||
const header = headers.value.find(h => h.value === value);
|
||||
if (header) {
|
||||
header.enabled = !header.enabled; // Toggle the 'enabled' state
|
||||
}
|
||||
|
||||
preferences.value.tableHeaders = headers.value;
|
||||
};
|
||||
const moveHeader = (from: number, to: number) => {
|
||||
const header = headers.value[from];
|
||||
if (!header) {
|
||||
return;
|
||||
}
|
||||
headers.value.splice(from, 1);
|
||||
headers.value.splice(to, 0, header);
|
||||
|
||||
preferences.value.tableHeaders = headers.value;
|
||||
};
|
||||
|
||||
const pagination = reactive({
|
||||
descending: false,
|
||||
page: 1,
|
||||
rowsPerPage: preferences.value.itemsPerTablePage,
|
||||
rowsNumber: 0,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => pagination.rowsPerPage,
|
||||
newRowsPerPage => {
|
||||
preferences.value.itemsPerTablePage = newRowsPerPage;
|
||||
}
|
||||
);
|
||||
|
||||
function sortBy(property: keyof ItemSummary) {
|
||||
if (sortByProperty.value === property) {
|
||||
pagination.descending = !pagination.descending;
|
||||
} else {
|
||||
pagination.descending = false;
|
||||
}
|
||||
sortByProperty.value = property;
|
||||
}
|
||||
|
||||
function extractSortable(item: ItemSummary, property: keyof ItemSummary): string | number | boolean {
|
||||
const value = item[property];
|
||||
if (typeof value === "string") {
|
||||
// Try to parse number
|
||||
const parsed = Number(value);
|
||||
if (!isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
if (typeof value !== "number" && typeof value !== "boolean") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function itemSort(a: ItemSummary, b: ItemSummary) {
|
||||
if (!sortByProperty.value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const aVal = extractSortable(a, sortByProperty.value);
|
||||
const bVal = extractSortable(b, sortByProperty.value);
|
||||
|
||||
if (typeof aVal === "string" && typeof bVal === "string") {
|
||||
return aVal.localeCompare(bVal, undefined, { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
|
||||
if (aVal < bVal) {
|
||||
return -1;
|
||||
}
|
||||
if (aVal > bVal) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const data = computed<TableData[]>(() => {
|
||||
// sort by property
|
||||
let data = [...props.items].sort(itemSort);
|
||||
|
||||
// sort descending
|
||||
if (pagination.descending) {
|
||||
data.reverse();
|
||||
}
|
||||
|
||||
// paginate
|
||||
const start = (pagination.page - 1) * pagination.rowsPerPage;
|
||||
const end = start + pagination.rowsPerPage;
|
||||
data = data.slice(start, end);
|
||||
return data;
|
||||
});
|
||||
|
||||
function extractValue(data: TableData, value: string) {
|
||||
const parts = value.split(".");
|
||||
let current = data;
|
||||
for (const part of parts) {
|
||||
current = current[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function cell(h: TableHeaderType) {
|
||||
return `cell-${h.value.replace(".", "_")}`;
|
||||
}
|
||||
const columns = computed(() => makeColumns(t).filter(c => c.enableHiding !== false));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable view="table" :data="items" :columns="columns" disable-controls />
|
||||
</template>
|
||||
|
||||
8
frontend/components/Item/View/pagination.ts
Normal file
8
frontend/components/Item/View/pagination.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { ShallowUnwrapRef } from "vue";
|
||||
|
||||
export type Pagination = ShallowUnwrapRef<{
|
||||
page: WritableComputedRef<number, number>;
|
||||
pageSize: ComputedRef<number>;
|
||||
totalSize: Ref<number, number>;
|
||||
setPage: (newPage: number) => void;
|
||||
}>;
|
||||
66
frontend/components/Item/View/table/card-view.vue
Normal file
66
frontend/components/Item/View/table/card-view.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import ItemCard from "@/components/Item/Card.vue";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
import type { Table as TableType } from "@tanstack/vue-table";
|
||||
import MdiSelectSearch from "~icons/mdi/select-search";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import DropdownAction from "./data-table-dropdown.vue";
|
||||
|
||||
const preferences = useViewPreferences();
|
||||
|
||||
const props = defineProps<{
|
||||
table: TableType<ItemSummary>;
|
||||
locationFlatTree?: FlatTreeItem[];
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: "refresh"): void;
|
||||
}>();
|
||||
|
||||
const selectedCount = computed(() => props.table.getSelectedRowModel().rows.length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="#selectable-subtitle" defer>
|
||||
<Checkbox
|
||||
class="size-6 p-0"
|
||||
:model-value="
|
||||
table.getIsAllPageRowsSelected() ? true : table.getSelectedRowModel().rows.length > 0 ? 'indeterminate' : false
|
||||
"
|
||||
:aria-label="$t('components.item.view.selectable.select_all')"
|
||||
@update:model-value="table.toggleAllPageRowsSelected(!!$event)"
|
||||
/>
|
||||
|
||||
<div class="grow" />
|
||||
|
||||
<div :class="['relative inline-flex items-center', selectedCount === 0 ? 'pointer-events-none opacity-50' : '']">
|
||||
<DropdownAction
|
||||
:multi="{ items: table.getSelectedRowModel().rows, columns: table.getAllColumns() }"
|
||||
view="card"
|
||||
:table="table"
|
||||
@refresh="$emit('refresh')"
|
||||
/>
|
||||
|
||||
<span v-if="selectedCount > 0" class="absolute -right-1 -top-1 flex size-4">
|
||||
<span
|
||||
class="pointer-events-none relative flex size-4 items-center justify-center whitespace-nowrap rounded-full bg-primary p-1 text-xs text-primary-foreground"
|
||||
>
|
||||
{{ String(selectedCount) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</Teleport>
|
||||
<div v-if="table.getRowModel().rows?.length === 0" class="flex flex-col items-center gap-2">
|
||||
<MdiSelectSearch class="size-10" />
|
||||
<p>{{ $t("items.no_results") }}</p>
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
<ItemCard
|
||||
v-for="item in table.getRowModel().rows"
|
||||
:key="item.original.id"
|
||||
:item="item.original"
|
||||
:table-row="preferences.quickActions.enabled ? item : undefined"
|
||||
:location-flat-tree="locationFlatTree"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
270
frontend/components/Item/View/table/columns.ts
Normal file
270
frontend/components/Item/View/table/columns.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import type { Column, ColumnDef } from "@tanstack/vue-table";
|
||||
import { h } from "vue";
|
||||
import DropdownAction from "./data-table-dropdown.vue";
|
||||
import { ArrowDown, ArrowUpDown, Check, X } from "lucide-vue-next";
|
||||
import Button from "~/components/ui/button/Button.vue";
|
||||
import Checkbox from "~/components/Form/Checkbox.vue";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
import Currency from "~/components/global/Currency.vue";
|
||||
import DateTime from "~/components/global/DateTime.vue";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
/**
|
||||
* Create columns with i18n support.
|
||||
* Pass `t` from useI18n() when creating the columns in your component.
|
||||
*/
|
||||
export function makeColumns(t: (key: string) => string, refresh?: () => void): ColumnDef<ItemSummary>[] {
|
||||
const sortable = (column: Column<ItemSummary, unknown>, key: string) => {
|
||||
const sortState = column.getIsSorted(); // 'asc' | 'desc' | false
|
||||
if (!sortState) {
|
||||
// show the neutral up/down icon when not sorted
|
||||
return [t(key), h(ArrowUpDown, { class: "ml-2 h-4 w-4 opacity-40" })];
|
||||
}
|
||||
// show a single arrow that points up for asc (rotate-180) and down for desc
|
||||
return [
|
||||
t(key),
|
||||
h(ArrowDown, {
|
||||
class: cn(["ml-2 h-4 w-4 transition-transform opacity-100", sortState === "asc" ? "rotate-180" : ""]),
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) =>
|
||||
h(Checkbox, {
|
||||
modelValue: table.getIsAllPageRowsSelected()
|
||||
? true
|
||||
: table.getSelectedRowModel().rows.length > 0
|
||||
? ("indeterminate" as unknown as boolean) // :)
|
||||
: false,
|
||||
"onUpdate:modelValue": (value: boolean) => table.toggleAllPageRowsSelected(!!value),
|
||||
ariaLabel: t("components.item.view.selectable.select_all"),
|
||||
}),
|
||||
cell: ({ row }) =>
|
||||
h(Checkbox, {
|
||||
modelValue: row.getIsSelected(),
|
||||
"onUpdate:modelValue": (value: boolean) => row.toggleSelected(!!value),
|
||||
ariaLabel: t("components.item.view.selectable.select_row"),
|
||||
}),
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "assetId",
|
||||
accessorKey: "assetId",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.asset_id")
|
||||
),
|
||||
cell: ({ row }) => h("div", { class: "text-sm" }, String(row.getValue("assetId") ?? "")),
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.name")
|
||||
),
|
||||
cell: ({ row }) => h("span", { class: "text-sm font-medium" }, row.getValue("name")),
|
||||
},
|
||||
{
|
||||
id: "quantity",
|
||||
accessorKey: "quantity",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.quantity")
|
||||
),
|
||||
cell: ({ row }) => h("div", { class: "text-center" }, String(row.getValue("quantity") ?? "")),
|
||||
},
|
||||
{
|
||||
id: "insured",
|
||||
accessorKey: "insured",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.insured")
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const val = row.getValue("insured");
|
||||
return h(
|
||||
"div",
|
||||
{ class: "block mx-auto w-min" },
|
||||
val ? h(Check, { class: "h-4 w-4 text-green-500" }) : h(X, { class: "h-4 w-4 text-destructive" })
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "purchasePrice",
|
||||
accessorKey: "purchasePrice",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.purchase_price")
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
h("div", { class: "text-center" }, h(Currency, { amount: Number(row.getValue("purchasePrice")) })),
|
||||
},
|
||||
{
|
||||
id: "location",
|
||||
accessorKey: "location",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.location")
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const loc = (row.original as ItemSummary).location as { id: string; name: string } | null;
|
||||
if (loc) {
|
||||
return h("NuxtLink", { to: `/location/${loc.id}`, class: "hover:underline text-sm" }, () => loc.name);
|
||||
}
|
||||
return h("div", { class: "text-sm text-muted-foreground" }, "");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "archived",
|
||||
accessorKey: "archived",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.archived")
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const val = row.getValue("archived");
|
||||
return h(
|
||||
"div",
|
||||
{ class: "block mx-auto w-min" },
|
||||
val ? h(Check, { class: "h-4 w-4 text-green-500" }) : h(X, { class: "h-4 w-4 text-destructive" })
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.created_at")
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
h(
|
||||
"div",
|
||||
{ class: "text-center text-sm" },
|
||||
h(DateTime, { date: row.getValue("createdAt") as Date, datetimeType: "date" })
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "updatedAt",
|
||||
accessorKey: "updatedAt",
|
||||
header: ({ column }) =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
},
|
||||
() => sortable(column, "items.updated_at")
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
h(
|
||||
"div",
|
||||
{ class: "text-center text-sm" },
|
||||
h(DateTime, { date: row.getValue("updatedAt") as Date, datetimeType: "date" })
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: ({ table }) => {
|
||||
const selectedCount = table.getSelectedRowModel().rows.length;
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
class: [
|
||||
"relative inline-flex items-center",
|
||||
selectedCount === 0 ? "opacity-50 pointer-events-none" : "",
|
||||
].join(" "),
|
||||
},
|
||||
[
|
||||
h(DropdownAction, {
|
||||
multi: {
|
||||
items: table.getSelectedRowModel().rows,
|
||||
columns: table.getAllColumns(),
|
||||
},
|
||||
onExpand: () => {
|
||||
table.getSelectedRowModel().rows.forEach(row => row.toggleExpanded());
|
||||
},
|
||||
view: "table",
|
||||
onRefresh: () => refresh?.(),
|
||||
table,
|
||||
}),
|
||||
selectedCount > 0 &&
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
class: "-right-1 -top-1 absolute flex size-4",
|
||||
},
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
class:
|
||||
"relative flex size-4 items-center justify-center rounded-full bg-primary p-1 text-primary-foreground text-xs pointer-events-none whitespace-nowrap",
|
||||
},
|
||||
String(selectedCount)
|
||||
)
|
||||
),
|
||||
]
|
||||
);
|
||||
},
|
||||
cell: ({ row, table }) => {
|
||||
const item = row.original;
|
||||
return h(
|
||||
"div",
|
||||
{ class: "relative" },
|
||||
h(DropdownAction, {
|
||||
item,
|
||||
onExpand: row.toggleExpanded,
|
||||
view: "table",
|
||||
onRefresh: () => refresh?.(),
|
||||
table,
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
93
frontend/components/Item/View/table/data-table-controls.vue
Normal file
93
frontend/components/Item/View/table/data-table-controls.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import type { Table as TableType } from "@tanstack/vue-table";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
|
||||
import MdiTableCog from "~icons/mdi/table-cog";
|
||||
|
||||
import Button from "~/components/ui/button/Button.vue";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationEllipsis,
|
||||
PaginationFirst,
|
||||
PaginationLast,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
} from "@/components/ui/pagination";
|
||||
import { DialogID, useDialog } from "~/components/ui/dialog-provider/utils";
|
||||
import type { Pagination as PaginationType } from "../pagination";
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const props = defineProps<{
|
||||
table: TableType<ItemSummary>;
|
||||
dataLength: number;
|
||||
externalPagination?: PaginationType;
|
||||
}>();
|
||||
|
||||
const setPage = (page: number) => {
|
||||
if (props.externalPagination) {
|
||||
if (page !== props.externalPagination.page) {
|
||||
// clear selection and expanded
|
||||
props.table.resetRowSelection();
|
||||
props.table.resetExpanded();
|
||||
}
|
||||
props.externalPagination.setPage(page);
|
||||
} else {
|
||||
props.table.setPageIndex(page - 1);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between md:gap-0">
|
||||
<div class="order-2 flex items-center gap-2 md:order-1">
|
||||
<Button class="size-10 p-0" variant="outline" @click="openDialog(DialogID.ItemTableSettings)">
|
||||
<MdiTableCog />
|
||||
</Button>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{
|
||||
$t("components.item.view.table.selected_rows", {
|
||||
selected: table.getFilteredSelectedRowModel().rows.length,
|
||||
total: table.getFilteredRowModel().rows.length,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="order-1 flex w-full justify-center md:order-2 md:w-auto">
|
||||
<Pagination
|
||||
v-slot="{ page }"
|
||||
:items-per-page="externalPagination ? externalPagination.pageSize : table.getState().pagination.pageSize"
|
||||
:total="externalPagination ? externalPagination.totalSize : dataLength"
|
||||
:sibling-count="2"
|
||||
:page="externalPagination ? externalPagination.page : table.getState().pagination.pageIndex + 1"
|
||||
@update:page="val => setPage(val)"
|
||||
>
|
||||
<PaginationList v-slot="{ items: pageItems }" class="flex items-center gap-1">
|
||||
<PaginationFirst @click="() => setPage(1)" />
|
||||
<template v-for="(item, index) in pageItems">
|
||||
<PaginationListItem v-if="item.type === 'page'" :key="index" :value="item.value" as-child>
|
||||
<Button
|
||||
class="size-10 p-0"
|
||||
:variant="item.value === page ? 'default' : 'outline'"
|
||||
@click="() => setPage(item.value)"
|
||||
>
|
||||
{{ item.value }}
|
||||
</Button>
|
||||
</PaginationListItem>
|
||||
<PaginationEllipsis v-else :key="item.type" :index="index" />
|
||||
</template>
|
||||
<PaginationLast
|
||||
@click="
|
||||
() =>
|
||||
setPage(
|
||||
externalPagination
|
||||
? Math.ceil(externalPagination.totalSize / externalPagination.pageSize)
|
||||
: table.getPageCount()
|
||||
)
|
||||
"
|
||||
/>
|
||||
</PaginationList>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
273
frontend/components/Item/View/table/data-table-dropdown.vue
Normal file
273
frontend/components/Item/View/table/data-table-dropdown.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<script setup lang="ts">
|
||||
import { MoreHorizontal } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
import type { Column, Row, Table } from "@tanstack/vue-table";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { toast } from "~/components/ui/sonner";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||
|
||||
const { t } = useI18n();
|
||||
const api = useUserApi();
|
||||
const confirm = useConfirm();
|
||||
const preferences = useViewPreferences();
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const props = defineProps<{
|
||||
item?: ItemSummary;
|
||||
multi?: {
|
||||
items: Row<ItemSummary>[];
|
||||
columns: Column<ItemSummary>[];
|
||||
};
|
||||
view: "table" | "card";
|
||||
table: Table<ItemSummary>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "expand"): void;
|
||||
(e: "refresh"): void;
|
||||
}>();
|
||||
|
||||
const resetSelection = () => {
|
||||
props.table.resetRowSelection();
|
||||
props.table.resetExpanded();
|
||||
emit("refresh");
|
||||
};
|
||||
|
||||
const openMultiTab = async (items: string[]) => {
|
||||
if (!preferences.value.shownMultiTabWarning) {
|
||||
// TODO: add warning with link to docs and just improve this
|
||||
const { isCanceled } = await confirm.open({
|
||||
message: t("components.item.view.table.dropdown.open_multi_tab_warning"),
|
||||
href: "https://homebox.software/en/user-guide/tips-tricks#open-multiple-items-in-new-tabs",
|
||||
});
|
||||
if (isCanceled) {
|
||||
return;
|
||||
}
|
||||
preferences.value.shownMultiTabWarning = true;
|
||||
}
|
||||
|
||||
items.forEach(item => window.open(`/item/${item}`, "_blank"));
|
||||
};
|
||||
|
||||
const escapeCsvField = (value: unknown): string => {
|
||||
let str = String(value ?? "");
|
||||
// Mitigate formula injection
|
||||
if (/^[=+\-@]/.test(str)) {
|
||||
str = "'" + str;
|
||||
}
|
||||
// Escape double quotes
|
||||
str = str.replace(/"/g, '""');
|
||||
// Wrap in double quotes
|
||||
return `"${str}"`;
|
||||
};
|
||||
|
||||
const downloadCsv = (items: Row<ItemSummary>[], columns: Column<ItemSummary>[]) => {
|
||||
// get enabled columns
|
||||
const enabledColumns = columns.filter(c => c.id !== undefined && c.getIsVisible() && c.getCanHide()).map(c => c.id);
|
||||
|
||||
// create CSV header (escaped)
|
||||
const header = enabledColumns.map(escapeCsvField).join(",");
|
||||
|
||||
// map each item to a row matching enabled columns order, escaping each field
|
||||
const rows = items.map(item =>
|
||||
enabledColumns.map(col => escapeCsvField(item.original[col as keyof ItemSummary])).join(",")
|
||||
);
|
||||
|
||||
const csv = [header, ...rows].join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "items.csv";
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const downloadJson = (items: Row<ItemSummary>[], columns: Column<ItemSummary>[]) => {
|
||||
// get enabled columns
|
||||
const enabledColumns = columns.filter(c => c.id !== undefined && c.getIsVisible() && c.getCanHide()).map(c => c.id);
|
||||
|
||||
// map each item to an object with only enabled columns
|
||||
const data = items.map(item => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
enabledColumns.forEach(col => {
|
||||
obj[col] = item.original[col as keyof ItemSummary] ?? null;
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
|
||||
const exportObj = {
|
||||
headers: enabledColumns,
|
||||
data,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(exportObj, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "items.json";
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const deleteItems = async (ids: string[]) => {
|
||||
const { isCanceled } = await confirm.open(t("components.item.view.table.dropdown.delete_confirmation"));
|
||||
|
||||
if (isCanceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.allSettled(
|
||||
ids.map(id =>
|
||||
api.items.delete(id).catch(err => {
|
||||
toast.error(t("components.item.view.table.dropdown.error_deleting"));
|
||||
console.error(err);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
resetSelection();
|
||||
};
|
||||
|
||||
const duplicateItems = async (ids: string[]) => {
|
||||
await Promise.allSettled(
|
||||
ids.map(id =>
|
||||
api.items
|
||||
.duplicate(id, {
|
||||
copyMaintenance: preferences.value.duplicateSettings.copyMaintenance,
|
||||
copyAttachments: preferences.value.duplicateSettings.copyAttachments,
|
||||
copyCustomFields: preferences.value.duplicateSettings.copyCustomFields,
|
||||
copyPrefix: preferences.value.duplicateSettings.copyPrefixOverride ?? t("items.duplicate.prefix"),
|
||||
})
|
||||
.catch(err => {
|
||||
toast.error(t("components.item.view.table.dropdown.error_duplicating"));
|
||||
console.error(err);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
resetSelection();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
:variant="view === 'table' ? 'ghost' : 'outline'"
|
||||
class="size-8 p-0 hover:bg-primary hover:text-primary-foreground"
|
||||
>
|
||||
<span class="sr-only">{{ t("components.item.view.table.dropdown.open_menu") }}</span>
|
||||
<MoreHorizontal class="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{{ t("components.item.view.table.dropdown.actions") }}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem v-if="item" as-child>
|
||||
<NuxtLink :to="`/item/${item.id}`" class="hover:underline">
|
||||
{{ t("components.item.view.table.dropdown.view_item") }}
|
||||
</NuxtLink>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="multi" @click="openMultiTab(multi.items.map(row => row.original.id))">
|
||||
{{ t("components.item.view.table.dropdown.view_items") }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="view === 'table'" @click="$emit('expand')">
|
||||
{{ t("components.item.view.table.dropdown.toggle_expand") }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<!-- change location -->
|
||||
<DropdownMenuItem
|
||||
@click="
|
||||
openDialog(DialogID.ItemChangeDetails, {
|
||||
params: { items: multi ? multi.items.map(row => row.original) : [item!], changeLocation: true },
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
toast.success(t('components.item.view.table.dropdown.change_location_success'));
|
||||
resetSelection();
|
||||
}
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ t("components.item.view.table.dropdown.change_location") }}
|
||||
</DropdownMenuItem>
|
||||
<!-- change labels -->
|
||||
<DropdownMenuItem
|
||||
@click="
|
||||
openDialog(DialogID.ItemChangeDetails, {
|
||||
params: {
|
||||
items: multi ? multi.items.map(row => row.original) : [item!],
|
||||
addLabels: true,
|
||||
removeLabels: true,
|
||||
},
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
toast.success(t('components.item.view.table.dropdown.change_labels_success'));
|
||||
resetSelection();
|
||||
}
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ t("components.item.view.table.dropdown.change_labels") }}
|
||||
</DropdownMenuItem>
|
||||
<!-- maintenance -->
|
||||
<DropdownMenuItem
|
||||
@click="
|
||||
openDialog(DialogID.EditMaintenance, {
|
||||
params: { type: 'create', itemId: multi ? multi.items.map(row => row.original.id) : item!.id },
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
toast.success(t('components.item.view.table.dropdown.create_maintenance_success'));
|
||||
}
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
{{
|
||||
multi
|
||||
? t("components.item.view.table.dropdown.create_maintenance_selected")
|
||||
: t("components.item.view.table.dropdown.create_maintenance_item")
|
||||
}}
|
||||
</DropdownMenuItem>
|
||||
<!-- duplicate -->
|
||||
<DropdownMenuItem @click="duplicateItems(multi ? multi.items.map(row => row.original.id) : [item!.id])">
|
||||
{{
|
||||
multi
|
||||
? t("components.item.view.table.dropdown.duplicate_selected")
|
||||
: t("components.item.view.table.dropdown.duplicate_item")
|
||||
}}
|
||||
</DropdownMenuItem>
|
||||
<!-- delete -->
|
||||
<DropdownMenuItem @click="deleteItems(multi ? multi.items.map(row => row.original.id) : [item!.id])">
|
||||
{{
|
||||
multi
|
||||
? t("components.item.view.table.dropdown.delete_selected")
|
||||
: t("components.item.view.table.dropdown.delete_item")
|
||||
}}
|
||||
</DropdownMenuItem>
|
||||
<!-- download -->
|
||||
<DropdownMenuSeparator v-if="multi && view === 'table'" />
|
||||
<DropdownMenuItem v-if="multi && view === 'table'" @click="downloadCsv(multi.items, multi.columns)">
|
||||
{{ t("components.item.view.table.dropdown.download_csv") }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="multi && view === 'table'" @click="downloadJson(multi.items, multi.columns)">
|
||||
{{ t("components.item.view.table.dropdown.download_json") }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
import LabelChip from "@/components/Label/Chip.vue";
|
||||
import Badge from "~/components/ui/badge/Badge.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
item: ItemSummary;
|
||||
}>();
|
||||
|
||||
const api = useUserApi();
|
||||
|
||||
const imageUrl = computed(() => {
|
||||
if (!props.item.imageId) {
|
||||
return "/no-image.jpg";
|
||||
}
|
||||
if (props.item.thumbnailId) {
|
||||
return api.authURL(`/items/${props.item.id}/attachments/${props.item.thumbnailId}`);
|
||||
} else {
|
||||
return api.authURL(`/items/${props.item.id}/attachments/${props.item.imageId}`);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="shrink-0">
|
||||
<img :src="imageUrl" class="size-32 rounded-lg bg-muted object-cover" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<h2 class="truncate text-xl font-bold">{{ item.name }}</h2>
|
||||
<Badge class="w-min text-nowrap bg-secondary text-secondary-foreground hover:bg-secondary/70 hover:underline">
|
||||
<NuxtLink v-if="item.location" :to="`/location/${item.location.id}`">
|
||||
{{ item.location.name }}
|
||||
</NuxtLink>
|
||||
</Badge>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<LabelChip v-for="label in item.labels" :key="label.id" :label="label" size="sm" />
|
||||
</div>
|
||||
<p class="whitespace-pre-line break-words text-sm text-muted-foreground">
|
||||
{{ item.description || $t("components.item.no_description") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
252
frontend/components/Item/View/table/data-table.vue
Normal file
252
frontend/components/Item/View/table/data-table.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts" generic="TData, TValue">
|
||||
import BaseCard from "@/components/Base/Card.vue";
|
||||
import type { ColumnDef, SortingState, VisibilityState, ExpandedState } from "@tanstack/vue-table";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
getExpandedRowModel,
|
||||
useVueTable,
|
||||
} from "@tanstack/vue-table";
|
||||
|
||||
import { camelToSnakeCase, valueUpdater } from "@/lib/utils";
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import Button from "~/components/ui/button/Button.vue";
|
||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||
import MdiArrowDown from "~icons/mdi/arrow-down";
|
||||
import MdiArrowUp from "~icons/mdi/arrow-up";
|
||||
import Checkbox from "~/components/ui/checkbox/Checkbox.vue";
|
||||
import Label from "~/components/ui/label/Label.vue";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
|
||||
import TableView from "./table-view.vue";
|
||||
import CardView from "./card-view.vue";
|
||||
import DataTableControls from "./data-table-controls.vue";
|
||||
import type { Pagination } from "../pagination";
|
||||
import Switch from "~/components/ui/switch/Switch.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
columns: ColumnDef<ItemSummary, TValue>[];
|
||||
data: ItemSummary[];
|
||||
disableControls?: boolean;
|
||||
view: "table" | "card";
|
||||
locationFlatTree?: FlatTreeItem[];
|
||||
externalPagination?: Pagination;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: "refresh"): void;
|
||||
}>();
|
||||
|
||||
const preferences = useViewPreferences();
|
||||
const defaultPageSize = preferences.value.itemsPerTablePage;
|
||||
const tableHeadersData = preferences.value.tableHeaders;
|
||||
const defaultVisible = ["name", "quantity", "insured", "purchasePrice"];
|
||||
|
||||
const tableHeaders = computed(
|
||||
() =>
|
||||
tableHeadersData ??
|
||||
props.columns
|
||||
.filter(c => c.enableHiding !== false)
|
||||
.map(c => ({
|
||||
value: c.id!,
|
||||
enabled: defaultVisible.includes(c.id ?? ""),
|
||||
}))
|
||||
);
|
||||
|
||||
const sorting = ref<SortingState>([]);
|
||||
const columnOrder = ref<string[]>([
|
||||
"select",
|
||||
...(tableHeaders.value ? tableHeaders.value.map(h => h.value) : []),
|
||||
"actions",
|
||||
]);
|
||||
const columnVisibility = ref<VisibilityState>(
|
||||
tableHeaders.value?.reduce((acc, h) => ({ ...acc, [h.value]: h.enabled }), {})
|
||||
);
|
||||
const rowSelection = ref({});
|
||||
const expanded = ref<ExpandedState>({});
|
||||
const pagination = ref({
|
||||
pageIndex: 0,
|
||||
pageSize: defaultPageSize || 12,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => pagination.value.pageSize,
|
||||
newSize => {
|
||||
preferences.value.itemsPerTablePage = newSize;
|
||||
}
|
||||
);
|
||||
|
||||
const table = useVueTable<ItemSummary>({
|
||||
manualPagination: !!props.externalPagination,
|
||||
|
||||
get data() {
|
||||
return props.data;
|
||||
},
|
||||
get columns() {
|
||||
return props.columns;
|
||||
},
|
||||
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
|
||||
onSortingChange: updaterOrValue => valueUpdater(updaterOrValue, sorting),
|
||||
onColumnVisibilityChange: updaterOrValue => valueUpdater(updaterOrValue, columnVisibility),
|
||||
onRowSelectionChange: updaterOrValue => valueUpdater(updaterOrValue, rowSelection),
|
||||
onExpandedChange: updaterOrValue => valueUpdater(updaterOrValue, expanded),
|
||||
onColumnOrderChange: updaterOrValue => valueUpdater(updaterOrValue, columnOrder),
|
||||
onPaginationChange: updaterOrValue => valueUpdater(updaterOrValue, pagination),
|
||||
|
||||
state: {
|
||||
get sorting() {
|
||||
return sorting.value;
|
||||
},
|
||||
get columnVisibility() {
|
||||
return columnVisibility.value;
|
||||
},
|
||||
get rowSelection() {
|
||||
return rowSelection.value;
|
||||
},
|
||||
get expanded() {
|
||||
return expanded.value;
|
||||
},
|
||||
get columnOrder() {
|
||||
return columnOrder.value;
|
||||
},
|
||||
get pagination() {
|
||||
return pagination.value;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const persistHeaders = () => {
|
||||
const headers = table
|
||||
.getAllColumns()
|
||||
.filter(column => column.getCanHide())
|
||||
.map(h => ({
|
||||
value: h.id as keyof ItemSummary,
|
||||
enabled: h.getIsVisible(),
|
||||
}));
|
||||
|
||||
preferences.value.tableHeaders = headers;
|
||||
};
|
||||
|
||||
const moveHeader = (from: number, to: number) => {
|
||||
// Only allow moving between the first and last index (excluding 'select' and 'actions')
|
||||
const start = 1; // index of 'select'
|
||||
const end = columnOrder.value.length - 2; // index before 'actions'
|
||||
|
||||
if (from < start || from > end || to < start || to > end || from === to) return;
|
||||
|
||||
const order = [...columnOrder.value];
|
||||
const [moved] = order.splice(from, 1);
|
||||
order.splice(to, 0, moved!);
|
||||
columnOrder.value = order;
|
||||
|
||||
persistHeaders();
|
||||
};
|
||||
|
||||
const toggleHeader = (id: string) => {
|
||||
const header = table
|
||||
.getAllColumns()
|
||||
.filter(column => column.getCanHide())
|
||||
.find(h => h.id === id);
|
||||
if (header) {
|
||||
header.toggleVisibility();
|
||||
}
|
||||
|
||||
persistHeaders();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Dialog :dialog-id="DialogID.ItemTableSettings">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ $t("components.item.view.table.table_settings") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-if="props.view === 'table'" class="flex flex-col gap-2">
|
||||
<div>{{ $t("components.item.view.table.headers") }}</div>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
v-for="(colId, i) in columnOrder.slice(1, columnOrder.length - 1)"
|
||||
:key="colId"
|
||||
class="flex flex-row items-center gap-1"
|
||||
>
|
||||
<Button size="icon" class="size-6" variant="ghost" :disabled="i === 0" @click="moveHeader(i + 1, i)">
|
||||
<MdiArrowUp />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
class="size-6"
|
||||
variant="ghost"
|
||||
:disabled="i === columnOrder.length - 3"
|
||||
@click="moveHeader(i + 1, i + 2)"
|
||||
>
|
||||
<MdiArrowDown />
|
||||
</Button>
|
||||
<Checkbox
|
||||
:id="colId"
|
||||
:model-value="table.getColumn(colId)?.getIsVisible()"
|
||||
@update:model-value="toggleHeader(colId)"
|
||||
/>
|
||||
<label class="text-sm" :for="colId"> {{ $t(`items.${camelToSnakeCase(colId)}`) }} </label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label> {{ $t("components.item.view.table.rows_per_page") }} </Label>
|
||||
<Select :model-value="pagination.pageSize" @update:model-value="val => table.setPageSize(Number(val))">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="12">12</SelectItem>
|
||||
<SelectItem :value="24">24</SelectItem>
|
||||
<SelectItem :value="48">48</SelectItem>
|
||||
<SelectItem :value="96">96</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label class="text-sm"> {{ $t("components.item.view.table.quick_actions") }} </Label>
|
||||
<Switch v-model="preferences.quickActions.enabled" />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<BaseCard v-if="props.view === 'table'">
|
||||
<div>
|
||||
<TableView :table="table" :columns="columns" />
|
||||
</div>
|
||||
<div v-if="!props.disableControls" class="border-t p-3">
|
||||
<DataTableControls
|
||||
:table="table"
|
||||
:pagination="pagination"
|
||||
:data-length="data.length"
|
||||
:external-pagination="externalPagination"
|
||||
/>
|
||||
</div>
|
||||
</BaseCard>
|
||||
<div v-else>
|
||||
<CardView :table="table" :location-flat-tree="locationFlatTree" @refresh="$emit('refresh')" />
|
||||
<div v-if="!props.disableControls" class="pt-2">
|
||||
<DataTableControls
|
||||
:table="table"
|
||||
:pagination="pagination"
|
||||
:data-length="data.length"
|
||||
:external-pagination="externalPagination"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
73
frontend/components/Item/View/table/table-view.vue
Normal file
73
frontend/components/Item/View/table/table-view.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts" generic="TValue">
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import DataTableExpandedRow from "./data-table-expanded-row.vue";
|
||||
import { FlexRender, type Column, type ColumnDef, type Table as TableType } from "@tanstack/vue-table";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
|
||||
defineProps<{
|
||||
table: TableType<ItemSummary>;
|
||||
columns: ColumnDef<ItemSummary, TValue>[];
|
||||
}>();
|
||||
|
||||
const ariaSort = (column: Column<ItemSummary, unknown>) => {
|
||||
const s = column.getIsSorted();
|
||||
if (s === "asc") return "ascending";
|
||||
if (s === "desc") return "descending";
|
||||
return "none";
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Table class="w-full">
|
||||
<TableHeader>
|
||||
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
|
||||
<TableHead
|
||||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
:class="[
|
||||
'text-no-transform cursor-pointer bg-secondary text-sm text-secondary-foreground hover:bg-secondary/90',
|
||||
header.column.id === 'select' || header.column.id === 'actions' ? 'w-10 px-3 text-center' : '',
|
||||
]"
|
||||
:aria-sort="ariaSort(header.column)"
|
||||
>
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<template v-if="table.getRowModel().rows?.length">
|
||||
<template v-for="row in table.getRowModel().rows" :key="row.id">
|
||||
<TableRow :data-state="row.getIsSelected() ? 'selected' : undefined">
|
||||
<TableCell
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
:href="
|
||||
cell.column.id !== 'select' && cell.column.id !== 'actions' ? `/item/${row.original.id}` : undefined
|
||||
"
|
||||
:class="cell.column.id === 'select' || cell.column.id === 'actions' ? 'w-10 px-3' : ''"
|
||||
:compact="cell.column.id === 'select' || cell.column.id === 'actions'"
|
||||
>
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="row.getIsExpanded()">
|
||||
<TableCell :colspan="row.getAllCells().length">
|
||||
<DataTableExpandedRow :item="row.original" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<TableRow>
|
||||
<TableCell :colspan="columns.length" class="h-24 text-center">
|
||||
<p>{{ $t("items.no_results") }}</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label :for="id" class="px-1">
|
||||
{{ $t("global.labels") }}
|
||||
{{ props.name ?? $t("global.labels") }}
|
||||
</Label>
|
||||
|
||||
<TagsInput
|
||||
@@ -108,6 +108,11 @@
|
||||
type: Array as () => LabelOut[],
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const modelValue = useVModel(props, "modelValue", emit);
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { DialogID } from "@/components/ui/dialog-provider/utils";
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
import type { MaintenanceEntry, MaintenanceEntryWithDetails } from "~~/lib/api/types/data-contracts";
|
||||
import MdiPost from "~icons/mdi/post";
|
||||
import DatePicker from "~~/components/Form/DatePicker.vue";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
@@ -38,13 +37,11 @@
|
||||
import FormTextArea from "~/components/Form/TextArea.vue";
|
||||
import Button from "@/components/ui/button/Button.vue";
|
||||
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
const { closeDialog, registerOpenDialogCallback } = useDialog();
|
||||
|
||||
const { t } = useI18n();
|
||||
const api = useUserApi();
|
||||
|
||||
const emit = defineEmits(["changed"]);
|
||||
|
||||
const entry = reactive({
|
||||
id: null as string | null,
|
||||
name: "",
|
||||
@@ -52,7 +49,7 @@
|
||||
scheduledDate: null as Date | null,
|
||||
description: "",
|
||||
cost: "",
|
||||
itemId: null as string | null,
|
||||
itemIds: null as string[] | null,
|
||||
});
|
||||
|
||||
async function dispatchFormSubmit() {
|
||||
@@ -65,24 +62,28 @@
|
||||
}
|
||||
|
||||
async function createEntry() {
|
||||
if (!entry.itemId) {
|
||||
return;
|
||||
}
|
||||
const { error } = await api.items.maintenance.create(entry.itemId, {
|
||||
name: entry.name,
|
||||
completedDate: entry.completedDate ?? "",
|
||||
scheduledDate: entry.scheduledDate ?? "",
|
||||
description: entry.description,
|
||||
cost: parseFloat(entry.cost) ? entry.cost : "0",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(t("maintenance.toast.failed_to_create"));
|
||||
if (!entry.itemIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeDialog(DialogID.EditMaintenance);
|
||||
emit("changed");
|
||||
await Promise.allSettled(
|
||||
entry.itemIds.map(async itemId => {
|
||||
const { error } = await api.items.maintenance.create(itemId, {
|
||||
name: entry.name,
|
||||
completedDate: entry.completedDate ?? "",
|
||||
scheduledDate: entry.scheduledDate ?? "",
|
||||
description: entry.description,
|
||||
cost: parseFloat(entry.cost) ? entry.cost : "0",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(t("maintenance.toast.failed_to_create"));
|
||||
return;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
closeDialog(DialogID.EditMaintenance, true);
|
||||
}
|
||||
|
||||
async function editEntry() {
|
||||
@@ -103,73 +104,42 @@
|
||||
return;
|
||||
}
|
||||
|
||||
closeDialog(DialogID.EditMaintenance);
|
||||
emit("changed");
|
||||
closeDialog(DialogID.EditMaintenance, true);
|
||||
}
|
||||
|
||||
const openCreateModal = (itemId: string) => {
|
||||
entry.id = null;
|
||||
entry.name = "";
|
||||
entry.completedDate = null;
|
||||
entry.scheduledDate = null;
|
||||
entry.description = "";
|
||||
entry.cost = "";
|
||||
entry.itemId = itemId;
|
||||
openDialog(DialogID.EditMaintenance);
|
||||
};
|
||||
|
||||
const openUpdateModal = (maintenanceEntry: MaintenanceEntry | MaintenanceEntryWithDetails) => {
|
||||
entry.id = maintenanceEntry.id;
|
||||
entry.name = maintenanceEntry.name;
|
||||
entry.completedDate = new Date(maintenanceEntry.completedDate);
|
||||
entry.scheduledDate = new Date(maintenanceEntry.scheduledDate);
|
||||
entry.description = maintenanceEntry.description;
|
||||
entry.cost = maintenanceEntry.cost;
|
||||
entry.itemId = null;
|
||||
openDialog(DialogID.EditMaintenance);
|
||||
};
|
||||
|
||||
const confirm = useConfirm();
|
||||
|
||||
async function deleteEntry(id: string) {
|
||||
const result = await confirm.open(t("maintenance.modal.delete_confirmation"));
|
||||
if (result.isCanceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await api.maintenance.delete(id);
|
||||
|
||||
if (error) {
|
||||
toast.error(t("maintenance.toast.failed_to_delete"));
|
||||
return;
|
||||
}
|
||||
emit("changed");
|
||||
}
|
||||
|
||||
async function complete(maintenanceEntry: MaintenanceEntry) {
|
||||
const { error } = await api.maintenance.update(maintenanceEntry.id, {
|
||||
name: maintenanceEntry.name,
|
||||
completedDate: new Date(Date.now()),
|
||||
scheduledDate: maintenanceEntry.scheduledDate ?? "null",
|
||||
description: maintenanceEntry.description,
|
||||
cost: maintenanceEntry.cost,
|
||||
onMounted(() => {
|
||||
const cleanup = registerOpenDialogCallback(DialogID.EditMaintenance, params => {
|
||||
switch (params.type) {
|
||||
case "create":
|
||||
entry.id = null;
|
||||
entry.name = "";
|
||||
entry.completedDate = null;
|
||||
entry.scheduledDate = null;
|
||||
entry.description = "";
|
||||
entry.cost = "";
|
||||
entry.itemIds = typeof params.itemId === "string" ? [params.itemId] : params.itemId;
|
||||
break;
|
||||
case "update":
|
||||
entry.id = params.maintenanceEntry.id;
|
||||
entry.name = params.maintenanceEntry.name;
|
||||
entry.completedDate = new Date(params.maintenanceEntry.completedDate);
|
||||
entry.scheduledDate = new Date(params.maintenanceEntry.scheduledDate);
|
||||
entry.description = params.maintenanceEntry.description;
|
||||
entry.cost = params.maintenanceEntry.cost;
|
||||
entry.itemIds = null;
|
||||
break;
|
||||
case "duplicate":
|
||||
entry.id = null;
|
||||
entry.name = params.maintenanceEntry.name;
|
||||
entry.completedDate = null;
|
||||
entry.scheduledDate = null;
|
||||
entry.description = params.maintenanceEntry.description;
|
||||
entry.cost = params.maintenanceEntry.cost;
|
||||
entry.itemIds = [params.itemId];
|
||||
break;
|
||||
}
|
||||
});
|
||||
if (error) {
|
||||
toast.error(t("maintenance.toast.failed_to_update"));
|
||||
}
|
||||
emit("changed");
|
||||
}
|
||||
|
||||
function duplicate(maintenanceEntry: MaintenanceEntry | MaintenanceEntryWithDetails, itemId: string) {
|
||||
entry.id = null;
|
||||
entry.name = maintenanceEntry.name;
|
||||
entry.completedDate = null;
|
||||
entry.scheduledDate = null;
|
||||
entry.description = maintenanceEntry.description;
|
||||
entry.cost = maintenanceEntry.cost;
|
||||
entry.itemId = itemId;
|
||||
openDialog(DialogID.EditMaintenance);
|
||||
}
|
||||
|
||||
defineExpose({ openCreateModal, openUpdateModal, deleteEntry, complete, duplicate });
|
||||
onUnmounted(cleanup);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { MaintenanceEntryWithDetails } from "~~/lib/api/types/data-contracts";
|
||||
import type { MaintenanceEntry, MaintenanceEntryWithDetails } from "~~/lib/api/types/data-contracts";
|
||||
import { MaintenanceFilterStatus } from "~~/lib/api/types/data-contracts";
|
||||
import type { StatsFormat } from "~~/components/global/StatCard/types";
|
||||
import MdiCheck from "~icons/mdi/check";
|
||||
@@ -20,12 +20,16 @@
|
||||
import DateTime from "~/components/global/DateTime.vue";
|
||||
import Currency from "~/components/global/Currency.vue";
|
||||
import Markdown from "~/components/global/Markdown.vue";
|
||||
import { toast } from "@/components/ui/sonner";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { DialogID } from "../ui/dialog-provider/utils";
|
||||
|
||||
const maintenanceFilterStatus = ref(MaintenanceFilterStatus.MaintenanceFilterStatusScheduled);
|
||||
const maintenanceEditModal = ref<InstanceType<typeof MaintenanceEditModal>>();
|
||||
|
||||
const api = useUserApi();
|
||||
const { t } = useI18n();
|
||||
const confirm = useConfirm();
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const props = defineProps({
|
||||
currentItemId: {
|
||||
@@ -81,6 +85,35 @@
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
async function deleteEntry(id: string) {
|
||||
const result = await confirm.open(t("maintenance.modal.delete_confirmation"));
|
||||
if (result.isCanceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await api.maintenance.delete(id);
|
||||
|
||||
if (error) {
|
||||
toast.error(t("maintenance.toast.failed_to_delete"));
|
||||
return;
|
||||
}
|
||||
refreshList();
|
||||
}
|
||||
|
||||
async function completeEntry(maintenanceEntry: MaintenanceEntry) {
|
||||
const { error } = await api.maintenance.update(maintenanceEntry.id, {
|
||||
name: maintenanceEntry.name,
|
||||
completedDate: new Date(Date.now()),
|
||||
scheduledDate: maintenanceEntry.scheduledDate ?? "null",
|
||||
description: maintenanceEntry.description,
|
||||
cost: maintenanceEntry.cost,
|
||||
});
|
||||
if (error) {
|
||||
toast.error(t("maintenance.toast.failed_to_update"));
|
||||
}
|
||||
refreshList();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -122,7 +155,16 @@
|
||||
v-if="props.currentItemId"
|
||||
class="ml-auto"
|
||||
size="sm"
|
||||
@click="maintenanceEditModal?.openCreateModal(props.currentItemId)"
|
||||
@click="
|
||||
openDialog(DialogID.EditMaintenance, {
|
||||
params: { type: 'create', itemId: props.currentItemId },
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
refreshList();
|
||||
}
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
<MdiPlus />
|
||||
{{ $t("maintenance.list.new") }}
|
||||
@@ -171,24 +213,44 @@
|
||||
<Markdown :source="e.description" />
|
||||
</div>
|
||||
<ButtonGroup class="flex flex-wrap justify-end p-4">
|
||||
<Button size="sm" @click="maintenanceEditModal?.openUpdateModal(e)">
|
||||
<Button
|
||||
size="sm"
|
||||
@click="
|
||||
openDialog(DialogID.EditMaintenance, {
|
||||
params: { type: 'update', maintenanceEntry: e },
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
refreshList();
|
||||
}
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
<MdiEdit />
|
||||
{{ $t("maintenance.list.edit") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!validDate(e.completedDate)"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="maintenanceEditModal?.complete(e)"
|
||||
>
|
||||
<Button v-if="!validDate(e.completedDate)" size="sm" variant="outline" @click="completeEntry(e)">
|
||||
<MdiCheck />
|
||||
{{ $t("maintenance.list.complete") }}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" @click="maintenanceEditModal?.duplicate(e, e.itemID)">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="
|
||||
openDialog(DialogID.EditMaintenance, {
|
||||
params: { type: 'duplicate', maintenanceEntry: e, itemId: props.currentItemId! },
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
refreshList();
|
||||
}
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
<MdiContentDuplicate />
|
||||
{{ $t("maintenance.list.duplicate") }}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" @click="maintenanceEditModal?.deleteEntry(e.id)">
|
||||
<Button size="sm" variant="destructive" @click="deleteEntry(e.id)">
|
||||
<MdiDelete />
|
||||
{{ $t("maintenance.list.delete") }}
|
||||
</Button>
|
||||
@@ -198,7 +260,16 @@
|
||||
<button
|
||||
type="button"
|
||||
class="relative block w-full rounded-lg border-2 border-dashed p-12 text-center"
|
||||
@click="maintenanceEditModal?.openCreateModal(props.currentItemId)"
|
||||
@click="
|
||||
openDialog(DialogID.EditMaintenance, {
|
||||
params: { type: 'create', itemId: props.currentItemId },
|
||||
onClose: result => {
|
||||
if (result) {
|
||||
refreshList();
|
||||
}
|
||||
},
|
||||
})
|
||||
"
|
||||
>
|
||||
<MdiWrenchClock class="inline size-16" />
|
||||
<span class="mt-2 block text-sm font-medium text-gray-900"> {{ $t("maintenance.list.create_first") }} </span>
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{{ $t("global.confirm") }}</AlertDialogTitle>
|
||||
<AlertDialogDescription> {{ text || $t("global.delete_confirm") }} </AlertDialogDescription>
|
||||
<AlertDialogDescription>
|
||||
{{ text || $t("global.delete_confirm") }}
|
||||
</AlertDialogDescription>
|
||||
<div v-if="href && href !== ''">
|
||||
<a :href="href" target="_blank" rel="noopener noreferrer" class="break-all text-sm text-primary underline">
|
||||
{{ href }}
|
||||
</a>
|
||||
</div>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel @click="cancel(false)">
|
||||
@@ -30,7 +37,7 @@
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
const { text, isRevealed, confirm, cancel } = useConfirm();
|
||||
const { text, href, isRevealed, confirm, cancel } = useConfirm();
|
||||
const { addAlert, removeAlert } = useDialog();
|
||||
|
||||
watch(
|
||||
|
||||
@@ -147,6 +147,4 @@
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Check } from 'lucide-vue-next'
|
||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check, Minus } from "lucide-vue-next";
|
||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui";
|
||||
import { computed, type HTMLAttributes } from "vue";
|
||||
|
||||
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<CheckboxRootEmits>()
|
||||
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>();
|
||||
const emits = defineEmits<CheckboxRootEmits>();
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props;
|
||||
|
||||
return delegated
|
||||
})
|
||||
return delegated;
|
||||
});
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CheckboxRoot
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn('peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
props.class)"
|
||||
cn(
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<CheckboxIndicator class="flex h-full w-full items-center justify-center text-current">
|
||||
<CheckboxIndicator class="flex size-full items-center justify-center text-current">
|
||||
<slot>
|
||||
<Check class="h-4 w-4" />
|
||||
<Check v-if="typeof props.modelValue === 'boolean'" class="size-4" />
|
||||
<Minus v-else class="size-4" />
|
||||
</slot>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/unified-signatures */
|
||||
import { computed, type ComputedRef } from "vue";
|
||||
import { createContext } from "reka-ui";
|
||||
import { useMagicKeys, useActiveElement } from "@vueuse/core";
|
||||
import type { BarcodeProduct } from "~~/lib/api/types/data-contracts";
|
||||
import type { BarcodeProduct, ItemSummary, MaintenanceEntry, MaintenanceEntryWithDetails } from "~~/lib/api/types/data-contracts";
|
||||
|
||||
export enum DialogID {
|
||||
AttachmentEdit = "attachment-edit",
|
||||
@@ -24,6 +23,7 @@ export enum DialogID {
|
||||
PageQRCode = "page-qr-code",
|
||||
UpdateLabel = "update-label",
|
||||
UpdateLocation = "update-location",
|
||||
ItemChangeDetails = "item-table-updater",
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +50,16 @@ export type DialogParamsMap = {
|
||||
};
|
||||
[DialogID.CreateItem]?: { product?: BarcodeProduct };
|
||||
[DialogID.ProductImport]?: { barcode?: string };
|
||||
[DialogID.EditMaintenance]:
|
||||
| { type: "create"; itemId: string | string[] }
|
||||
| { type: "update"; maintenanceEntry: MaintenanceEntry | MaintenanceEntryWithDetails }
|
||||
| { type: "duplicate"; maintenanceEntry: MaintenanceEntry | MaintenanceEntryWithDetails; itemId: string };
|
||||
[DialogID.ItemChangeDetails]: {
|
||||
items: ItemSummary[];
|
||||
changeLocation?: boolean;
|
||||
addLabels?: boolean;
|
||||
removeLabels?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -57,6 +67,8 @@ export type DialogParamsMap = {
|
||||
*/
|
||||
export type DialogResultMap = {
|
||||
[DialogID.ItemImage]?: { action: "delete"; id: string };
|
||||
[DialogID.EditMaintenance]?: boolean;
|
||||
[DialogID.ItemChangeDetails]?: boolean;
|
||||
};
|
||||
|
||||
/** Helpers to split IDs by requirement */
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { HTMLAttributes } from "vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"];
|
||||
href?: string;
|
||||
compact?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<td
|
||||
:class="
|
||||
cn(
|
||||
'p-4 align-middle [&:has([role=checkbox])]:pr-0',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<td :class="cn('p-0 align-middle relative', props.class)">
|
||||
<NuxtLink
|
||||
v-if="props.href"
|
||||
:to="props.href"
|
||||
class="block size-full"
|
||||
:class="props.compact ? 'p-0' : 'p-4'"
|
||||
style="min-width: 100%; min-height: 100%; display: flex; align-items: stretch"
|
||||
>
|
||||
<span class="flex min-h-0 min-w-0 flex-1 items-center"> <slot /> </span>
|
||||
</NuxtLink>
|
||||
<template v-else>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex size-full min-h-0 min-w-0 items-center [&:has([role=checkbox])]:pr-0',
|
||||
props.compact ? 'justify-center p-0' : 'p-4'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
</template>
|
||||
|
||||
@@ -4,12 +4,14 @@ import type { Ref } from "vue";
|
||||
|
||||
type Store = UseConfirmDialogReturn<any, boolean, boolean> & {
|
||||
text: Ref<string>;
|
||||
href: Ref<string>;
|
||||
setup: boolean;
|
||||
open: (text: string) => Promise<UseConfirmDialogRevealResult<boolean, boolean>>;
|
||||
open: (text: string | { message: string; href?: string }) => Promise<UseConfirmDialogRevealResult<boolean, boolean>>;
|
||||
};
|
||||
|
||||
const store: Partial<Store> = {
|
||||
text: ref("Are you sure you want to delete this item? "),
|
||||
href: ref(""),
|
||||
setup: false,
|
||||
};
|
||||
|
||||
@@ -31,15 +33,26 @@ export function useConfirm(): Store {
|
||||
store.cancel = cancel;
|
||||
}
|
||||
|
||||
async function openDialog(msg: string): Promise<UseConfirmDialogRevealResult<boolean, boolean>> {
|
||||
async function openDialog(
|
||||
msg: string | { message: string; href?: string }
|
||||
): Promise<UseConfirmDialogRevealResult<boolean, boolean>> {
|
||||
if (!store.reveal) {
|
||||
throw new Error("reveal is not defined");
|
||||
}
|
||||
if (!store.text) {
|
||||
throw new Error("text is not defined");
|
||||
}
|
||||
if (store.href === undefined) {
|
||||
throw new Error("href is not defined");
|
||||
}
|
||||
|
||||
store.text.value = msg;
|
||||
store.href.value = "";
|
||||
if (typeof msg === "string") {
|
||||
store.text.value = msg;
|
||||
} else {
|
||||
store.text.value = msg.message;
|
||||
store.href.value = msg.href ?? "";
|
||||
}
|
||||
return await store.reveal();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Ref } from "vue";
|
||||
import type { TableHeaderType } from "~/components/Item/View/Table.types";
|
||||
import type { ItemSummary } from "~/lib/api/types/data-contracts";
|
||||
import type { DaisyTheme } from "~~/lib/data/themes";
|
||||
|
||||
export type ViewType = "table" | "card" | "tree";
|
||||
export type ViewType = "table" | "card";
|
||||
|
||||
export type DuplicateSettings = {
|
||||
copyMaintenance: boolean;
|
||||
@@ -18,11 +18,18 @@ export type LocationViewPreferences = {
|
||||
itemDisplayView: ViewType;
|
||||
theme: DaisyTheme;
|
||||
itemsPerTablePage: number;
|
||||
tableHeaders?: TableHeaderType[];
|
||||
tableHeaders?: {
|
||||
value: keyof ItemSummary;
|
||||
enabled: boolean;
|
||||
}[];
|
||||
displayLegacyHeader: boolean;
|
||||
language?: string;
|
||||
overrideFormatLocale?: string;
|
||||
duplicateSettings: DuplicateSettings;
|
||||
shownMultiTabWarning: boolean;
|
||||
quickActions: {
|
||||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -48,6 +55,10 @@ export function useViewPreferences(): Ref<LocationViewPreferences> {
|
||||
copyCustomFields: true,
|
||||
copyPrefixOverride: null,
|
||||
},
|
||||
shownMultiTabWarning: false,
|
||||
quickActions: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
{ mergeDefaults: true }
|
||||
);
|
||||
|
||||
@@ -53,6 +53,7 @@ export default withNuxt([
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-invalid-void-type": "off",
|
||||
"@typescript-eslint/unified-signatures": "off",
|
||||
|
||||
"prettier/prettier": [
|
||||
"warn",
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
<SidebarInset class="min-h-dvh bg-background-accent">
|
||||
<SidebarInset class="min-h-dvh max-w-full overflow-hidden bg-background-accent">
|
||||
<div class="relative flex h-full flex-col justify-center">
|
||||
<div v-if="preferences.displayLegacyHeader">
|
||||
<AppHeaderDecor class="-mt-10 hidden lg:block" />
|
||||
|
||||
@@ -54,10 +54,10 @@ export enum AttachmentType {
|
||||
|
||||
export interface CurrenciesCurrency {
|
||||
code: string;
|
||||
decimals: number;
|
||||
local: string;
|
||||
name: string;
|
||||
symbol: string;
|
||||
decimals: number;
|
||||
}
|
||||
|
||||
export interface EntAttachment {
|
||||
@@ -465,6 +465,13 @@ export interface BarcodeProduct {
|
||||
search_engine_name: string;
|
||||
}
|
||||
|
||||
export interface DuplicateOptions {
|
||||
copyAttachments: boolean;
|
||||
copyCustomFields: boolean;
|
||||
copyMaintenance: boolean;
|
||||
copyPrefix: string;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
createdAt: Date | string;
|
||||
currency: string;
|
||||
@@ -571,6 +578,8 @@ export interface ItemOut {
|
||||
|
||||
export interface ItemPatch {
|
||||
id: string;
|
||||
labelIds?: string[] | null;
|
||||
locationId?: string | null;
|
||||
quantity?: number | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Updater } from "@tanstack/vue-table";
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -5,6 +6,11 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function valueUpdater<T extends Updater<any>>(updaterOrValue: T, ref: Ref) {
|
||||
ref.value = typeof updaterOrValue === "function" ? updaterOrValue(ref.value) : updaterOrValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns either '#000' or '#fff' depending on which has better contrast with the given background color.
|
||||
* Accepts hex (#RRGGBB or #RGB) or rgb(a) strings.
|
||||
@@ -36,3 +42,5 @@ export function getContrastTextColor(bgColor: string): string {
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.5 ? "#000" : "#fff";
|
||||
}
|
||||
|
||||
export const camelToSnakeCase = (str: string) => str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
|
||||
|
||||
@@ -138,18 +138,53 @@
|
||||
"searching": "Searching…"
|
||||
},
|
||||
"view": {
|
||||
"change_details": {
|
||||
"title": "Change Item Details",
|
||||
"failed_to_update_item": "Failed to update item",
|
||||
"add_labels": "Add Labels",
|
||||
"remove_labels": "Remove Labels"
|
||||
},
|
||||
"selectable": {
|
||||
"card": "Card",
|
||||
"items": "Items",
|
||||
"no_items": "No Items to Display",
|
||||
"table": "Table"
|
||||
"table": "Table",
|
||||
"select_all": "Select All",
|
||||
"select_row": "Select Row",
|
||||
"select_card": "Select Card"
|
||||
},
|
||||
"table": {
|
||||
"headers": "Headers",
|
||||
"page": "Page",
|
||||
"rows_per_page": "Rows per page",
|
||||
"quick_actions": "Enable Quick Actions & Selection",
|
||||
"table_settings": "Table Settings",
|
||||
"view_item": "View Item"
|
||||
"view_item": "View Item",
|
||||
"selected_rows": "{selected} of {total} row(s) selected.",
|
||||
"dropdown": {
|
||||
"open_menu": "Open menu",
|
||||
"actions": "Actions",
|
||||
"view_item": "View item",
|
||||
"view_items": "View items",
|
||||
"toggle_expand": "Toggle Expand",
|
||||
"download_csv": "Download Table as CSV",
|
||||
"download_json": "Download Table as JSON",
|
||||
"delete_selected": "Delete Selected Items",
|
||||
"delete_item": "Delete Item",
|
||||
"error_deleting": "Error Deleting Item",
|
||||
"delete_confirmation": "Are you sure you want to delete the selected item(s)? This action cannot be undone.",
|
||||
"duplicate_selected": "Duplicate Selected Items",
|
||||
"duplicate_item": "Duplicate Item",
|
||||
"error_duplicating": "Error Duplicating Item",
|
||||
"open_multi_tab_warning": "For security reasons browsers do not allow multiple tabs to be opened at once by default, to change this please follow the documentation:",
|
||||
"create_maintenance_selected": "Create Maintenance Entry for Selected Items",
|
||||
"create_maintenance_item": "Create Maintenance Entry for Item",
|
||||
"create_maintenance_success": "Maintenance Entry(s) Created",
|
||||
"change_location": "Change Location",
|
||||
"change_location_success": "Location Changed",
|
||||
"change_labels": "Change Labels",
|
||||
"change_labels_success": "Labels Changed"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vuepic/vue-datepicker": "^11.0.2",
|
||||
"@vueuse/core": "^13.8.0",
|
||||
"@vueuse/nuxt": "^13.8.0",
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
import BaseCard from "@/components/Base/Card.vue";
|
||||
import Subtitle from "~/components/global/Subtitle.vue";
|
||||
import StatCard from "~/components/global/StatCard/StatCard.vue";
|
||||
import ItemViewTable from "~/components/Item/View/Table.vue";
|
||||
import ItemCard from "~/components/Item/Card.vue";
|
||||
import LocationCard from "~/components/Location/Card.vue";
|
||||
import LabelChip from "~/components/Label/Chip.vue";
|
||||
import Table from "~/components/Item/View/Table.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
|
||||
<p v-if="itemTable.items.length === 0" class="ml-2 text-sm">{{ $t("items.no_results") }}</p>
|
||||
<BaseCard v-else-if="breakpoints.lg">
|
||||
<ItemViewTable :items="itemTable.items" disable-controls />
|
||||
<Table :items="itemTable.items" />
|
||||
</BaseCard>
|
||||
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<ItemCard v-for="item in itemTable.items" :key="item.id" :item="item" />
|
||||
|
||||
@@ -478,22 +478,28 @@
|
||||
return resp.data;
|
||||
});
|
||||
|
||||
const items = computedAsync(async () => {
|
||||
if (!item.value) {
|
||||
return [];
|
||||
const { data: items, refresh: refreshItemList } = useAsyncData(
|
||||
() => itemId.value + "_item_list",
|
||||
async () => {
|
||||
if (!itemId.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resp = await api.items.getAll({
|
||||
parentIds: [itemId.value],
|
||||
});
|
||||
|
||||
if (resp.error) {
|
||||
toast.error(t("items.toast.failed_load_items"));
|
||||
return [];
|
||||
}
|
||||
|
||||
return resp.data.items;
|
||||
},
|
||||
{
|
||||
watch: [itemId],
|
||||
}
|
||||
|
||||
const resp = await api.items.getAll({
|
||||
parentIds: [item.value.id],
|
||||
});
|
||||
|
||||
if (resp.error) {
|
||||
toast.error(t("items.toast.failed_load_items"));
|
||||
return [];
|
||||
}
|
||||
|
||||
return resp.data.items;
|
||||
});
|
||||
);
|
||||
|
||||
async function duplicateItem(settings?: DuplicateSettings) {
|
||||
if (!item.value) {
|
||||
@@ -787,7 +793,7 @@
|
||||
</section>
|
||||
|
||||
<section v-if="items && items.length > 0" class="mt-6">
|
||||
<ItemViewSelectable :items="items" />
|
||||
<ItemViewSelectable :items="items" @refresh="refreshItemList" />
|
||||
</section>
|
||||
</BaseContainer>
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { useLabelStore } from "~~/stores/labels";
|
||||
import { useLocationStore } from "~~/stores/locations";
|
||||
import MdiLoading from "~icons/mdi/loading";
|
||||
import MdiSelectSearch from "~icons/mdi/select-search";
|
||||
import MdiMagnify from "~icons/mdi/magnify";
|
||||
import MdiDelete from "~icons/mdi/delete";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -15,18 +14,9 @@
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationEllipsis,
|
||||
PaginationFirst,
|
||||
PaginationLast,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
} from "@/components/ui/pagination";
|
||||
import BaseContainer from "@/components/Base/Container.vue";
|
||||
import BaseSectionHeader from "@/components/Base/SectionHeader.vue";
|
||||
import SearchFilter from "~/components/Search/Filter.vue";
|
||||
import ItemCard from "~/components/Item/Card.vue";
|
||||
import ItemViewSelectable from "~/components/Item/View/Selectable.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -56,7 +46,6 @@
|
||||
},
|
||||
});
|
||||
|
||||
const pageSize = useRouteQuery("pageSize", 24);
|
||||
const query = useRouteQuery("q", "");
|
||||
const advanced = useRouteQuery("advanced", false);
|
||||
const includeArchived = useRouteQuery("archived", false);
|
||||
@@ -66,7 +55,8 @@
|
||||
const onlyWithPhoto = useRouteQuery("onlyWithPhoto", false);
|
||||
const orderBy = useRouteQuery("orderBy", "name");
|
||||
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize.value));
|
||||
const preferences = useViewPreferences();
|
||||
const pageSize = computed(() => preferences.value.itemsPerTablePage);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -243,8 +233,7 @@
|
||||
advanced: route.query.advanced,
|
||||
q: query.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
includeArchived: includeArchived.value ? "true" : "false",
|
||||
archived: includeArchived.value ? "true" : "false",
|
||||
negateLabels: negateLabels.value ? "true" : "false",
|
||||
onlyWithoutPhoto: onlyWithoutPhoto.value ? "true" : "false",
|
||||
onlyWithPhoto: onlyWithPhoto.value ? "true" : "false",
|
||||
@@ -330,7 +319,6 @@
|
||||
onlyWithoutPhoto: onlyWithoutPhoto.value ? "true" : "false",
|
||||
onlyWithPhoto: onlyWithPhoto.value ? "true" : "false",
|
||||
orderBy: orderBy.value,
|
||||
pageSize: pageSize.value,
|
||||
page: page.value,
|
||||
q: query.value,
|
||||
|
||||
@@ -361,7 +349,6 @@
|
||||
query: {
|
||||
archived: "false",
|
||||
fieldSelector: "false",
|
||||
pageSize: pageSize.value,
|
||||
page: 1,
|
||||
orderBy: "name",
|
||||
q: "",
|
||||
@@ -373,6 +360,15 @@
|
||||
|
||||
await search();
|
||||
}
|
||||
|
||||
const pagination = proxyRefs({
|
||||
page,
|
||||
pageSize,
|
||||
totalSize: total,
|
||||
setPage: (newPage: number) => {
|
||||
page.value = newPage;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -503,41 +499,12 @@
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<BaseSectionHeader ref="itemsTitle"> {{ $t("global.items") }} </BaseSectionHeader>
|
||||
<p v-if="items.length > 0" class="flex items-center text-base font-medium">
|
||||
{{ $t("items.results", { total: total }) }}
|
||||
<span class="ml-auto text-base"> {{ $t("items.pages", { page: page, totalPages: totalPages }) }} </span>
|
||||
</p>
|
||||
|
||||
<div v-if="items.length === 0" class="flex flex-col items-center gap-2">
|
||||
<MdiSelectSearch class="size-10" />
|
||||
<p>{{ $t("items.no_results") }}</p>
|
||||
</div>
|
||||
<div v-else ref="cardgrid" class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
<ItemCard v-for="item in items" :key="item.id" :item="item" :location-flat-tree="locationFlatTree" />
|
||||
</div>
|
||||
<Pagination
|
||||
v-slot="{ page: currentPage }"
|
||||
:items-per-page="pageSize"
|
||||
:total="total"
|
||||
:sibling-count="2"
|
||||
:default-page="page"
|
||||
class="flex justify-center p-2"
|
||||
@update:page="page = $event"
|
||||
>
|
||||
<PaginationList v-slot="{ items: pageItems }" class="flex items-center gap-1">
|
||||
<PaginationFirst />
|
||||
<template v-for="(item, index) in pageItems">
|
||||
<PaginationListItem v-if="item.type === 'page'" :key="index" :value="item.value" as-child>
|
||||
<Button class="size-10 p-0" :variant="item.value === currentPage ? 'default' : 'outline'">
|
||||
{{ item.value }}
|
||||
</Button>
|
||||
</PaginationListItem>
|
||||
<PaginationEllipsis v-else :key="item.type" :index="index" />
|
||||
</template>
|
||||
<PaginationLast />
|
||||
</PaginationList>
|
||||
</Pagination>
|
||||
<ItemViewSelectable
|
||||
:items="items"
|
||||
:location-flat-tree="locationFlatTree"
|
||||
:pagination="pagination"
|
||||
@refresh="async () => search()"
|
||||
/>
|
||||
</section>
|
||||
</BaseContainer>
|
||||
</template>
|
||||
|
||||
@@ -94,28 +94,34 @@
|
||||
updating.value = false;
|
||||
}
|
||||
|
||||
const items = computedAsync(async () => {
|
||||
if (!label.value) {
|
||||
return {
|
||||
items: [],
|
||||
totalPrice: null,
|
||||
};
|
||||
const { data: items, refresh: refreshItemList } = useAsyncData(
|
||||
() => labelId.value + "_item_list",
|
||||
async () => {
|
||||
if (!labelId.value) {
|
||||
return {
|
||||
items: [],
|
||||
totalPrice: null,
|
||||
};
|
||||
}
|
||||
|
||||
const resp = await api.items.getAll({
|
||||
labels: [labelId.value],
|
||||
});
|
||||
|
||||
if (resp.error) {
|
||||
toast.error(t("items.toast.failed_load_items"));
|
||||
return {
|
||||
items: [],
|
||||
totalPrice: null,
|
||||
};
|
||||
}
|
||||
|
||||
return resp.data;
|
||||
},
|
||||
{
|
||||
watch: [labelId],
|
||||
}
|
||||
|
||||
const resp = await api.items.getAll({
|
||||
labels: [label.value.id],
|
||||
});
|
||||
|
||||
if (resp.error) {
|
||||
toast.error(t("items.toast.failed_load_items"));
|
||||
return {
|
||||
items: [],
|
||||
totalPrice: null,
|
||||
};
|
||||
}
|
||||
|
||||
return resp.data;
|
||||
});
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -200,7 +206,7 @@
|
||||
<Markdown v-if="label && label.description" class="mt-3 text-base" :source="label.description" />
|
||||
</Card>
|
||||
<section v-if="label && items">
|
||||
<ItemViewSelectable :items="items.items" />
|
||||
<ItemViewSelectable :items="items.items" @refresh="refreshItemList" />
|
||||
</section>
|
||||
</BaseContainer>
|
||||
</template>
|
||||
|
||||
@@ -120,22 +120,28 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const parent = ref<LocationSummary | any>({});
|
||||
|
||||
const items = computedAsync(async () => {
|
||||
if (!location.value) {
|
||||
return [];
|
||||
const { data: items, refresh: refreshItemList } = useAsyncData(
|
||||
() => locationId.value + "_item_list",
|
||||
async () => {
|
||||
if (!locationId.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resp = await api.items.getAll({
|
||||
locations: [locationId.value],
|
||||
});
|
||||
|
||||
if (resp.error) {
|
||||
toast.error(t("items.toast.failed_load_items"));
|
||||
return [];
|
||||
}
|
||||
|
||||
return resp.data.items;
|
||||
},
|
||||
{
|
||||
watch: [locationId],
|
||||
}
|
||||
|
||||
const resp = await api.items.getAll({
|
||||
locations: [location.value.id],
|
||||
});
|
||||
|
||||
if (resp.error) {
|
||||
toast.error(t("items.toast.failed_load_items"));
|
||||
return [];
|
||||
}
|
||||
|
||||
return resp.data.items;
|
||||
});
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -228,7 +234,7 @@
|
||||
<Markdown v-if="location && location.description" class="mt-3 text-base" :source="location.description" />
|
||||
</Card>
|
||||
<section v-if="location && items">
|
||||
<ItemViewSelectable :items="items" />
|
||||
<ItemViewSelectable :items="items" @refresh="refreshItemList" />
|
||||
</section>
|
||||
|
||||
<section v-if="location && location.children.length > 0" class="mt-6">
|
||||
|
||||
20
frontend/pnpm-lock.yaml
generated
20
frontend/pnpm-lock.yaml
generated
@@ -29,6 +29,9 @@ importers:
|
||||
'@tailwindcss/typography':
|
||||
specifier: ^0.5.16
|
||||
version: 0.5.16(tailwindcss@3.4.17)
|
||||
'@tanstack/vue-table':
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3(vue@3.5.20(typescript@5.9.2))
|
||||
'@vuepic/vue-datepicker':
|
||||
specifier: ^11.0.2
|
||||
version: 11.0.2(vue@3.5.20(typescript@5.9.2))
|
||||
@@ -2187,9 +2190,19 @@ packages:
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
|
||||
|
||||
'@tanstack/table-core@8.21.3':
|
||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tanstack/virtual-core@3.13.12':
|
||||
resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==}
|
||||
|
||||
'@tanstack/vue-table@8.21.3':
|
||||
resolution: {integrity: sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
vue: '>=3.2'
|
||||
|
||||
'@tanstack/vue-virtual@3.13.12':
|
||||
resolution: {integrity: sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww==}
|
||||
peerDependencies:
|
||||
@@ -8897,8 +8910,15 @@ snapshots:
|
||||
postcss-selector-parser: 6.0.10
|
||||
tailwindcss: 3.4.17
|
||||
|
||||
'@tanstack/table-core@8.21.3': {}
|
||||
|
||||
'@tanstack/virtual-core@3.13.12': {}
|
||||
|
||||
'@tanstack/vue-table@8.21.3(vue@3.5.20(typescript@5.9.2))':
|
||||
dependencies:
|
||||
'@tanstack/table-core': 8.21.3
|
||||
vue: 3.5.20(typescript@5.9.2)
|
||||
|
||||
'@tanstack/vue-virtual@3.13.12(vue@3.5.20(typescript@5.9.2))':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.12
|
||||
|
||||
Reference in New Issue
Block a user