mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-21 21:33:02 +01:00
* 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
253 lines
8.2 KiB
Vue
253 lines
8.2 KiB
Vue
<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>
|