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:
Tonya
2025-09-24 02:37:38 +01:00
committed by GitHub
parent a5d63ac4e1
commit 6cd9e2779f
48 changed files with 1959 additions and 617 deletions

View File

@@ -3,6 +3,7 @@
<CardTitle class="flex items-center">
<slot />
</CardTitle>
<slot name="subtitle" />
<CardDescription v-if="$slots.description">
<slot name="description" />
</CardDescription>

View File

@@ -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) {

View File

@@ -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(

View 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>

View File

@@ -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>

View File

@@ -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>;

View File

@@ -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>

View 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;
}>;

View 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>

View 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,
})
);
},
},
];
}

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(

View File

@@ -147,6 +147,4 @@
overflow-wrap: break-word;
}
}
</style>

View File

@@ -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>

View File

@@ -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 */

View File

@@ -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>

View File

@@ -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();
}

View File

@@ -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 }
);

View File

@@ -53,6 +53,7 @@ export default withNuxt([
},
],
"@typescript-eslint/no-invalid-void-type": "off",
"@typescript-eslint/unified-signatures": "off",
"prettier/prettier": [
"warn",

View File

@@ -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" />

View File

@@ -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;
}

View File

@@ -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()}`);

View File

@@ -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"
}
}
}
},

View File

@@ -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",

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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