mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-21 21:33:02 +01:00
* chore: added `rel="noopener noreferrer"` to external links for improved security on homepage * fix: reused variable name * feat: make the item table use a link instead of a click event * fix: pagination now shows correct page if loading page that isnt 1 * fix: make save button sticky again on item edit page * fix: adjust sticky header positioning based on whether legacy header is enabled * fix: navigate to homepage on unauthorized request in useUserApi function * fix: enhance link handling in DetailsSection * fix: improve markdown rendering, don't add big breaks between lines, make the gap smaller and dont have a trailing newline * fix: use secondary colour as switch secondary colour so its more visible against bg * fix: call refresh methods on location and label stores during component mount for better data consistency * fix: loading indicators * fix: convert detail.text to string for correct URL handling in DetailsSection component * fix: use replace instead of splice to remove newline at end of markdown * fix: use css vars for header height
325 lines
11 KiB
Vue
325 lines
11 KiB
Vue
<template>
|
|
<Dialog dialog-id="item-table-settings">
|
|
<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('item-table-settings')"> {{ $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('item-table-settings')">
|
|
<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="{ 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, TableHeader, TableCell, TableHead, 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";
|
|
|
|
const { openDialog, closeDialog } = useDialog();
|
|
|
|
type Props = {
|
|
items: ItemSummary[];
|
|
disableControls?: boolean;
|
|
};
|
|
const props = defineProps<Props>();
|
|
|
|
const sortByProperty = ref<keyof ItemSummary | "">("");
|
|
|
|
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];
|
|
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 parse float
|
|
const parsed = parseFloat(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(".", "_")}`;
|
|
}
|
|
</script>
|