Files
homebox/frontend/components/Maintenance/ListView.vue
Tonya 6cd9e2779f 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
2025-09-24 02:37:38 +01:00

281 lines
9.6 KiB
Vue

<script setup lang="ts">
import { useI18n } from "vue-i18n";
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";
import MdiDelete from "~icons/mdi/delete";
import MdiEdit from "~icons/mdi/edit";
import MdiCalendar from "~icons/mdi/calendar";
import MdiPlus from "~icons/mdi/plus";
import MdiWrenchClock from "~icons/mdi/wrench-clock";
import MdiContentDuplicate from "~icons/mdi/content-duplicate";
import MaintenanceEditModal from "~~/components/Maintenance/EditModal.vue";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { Button, ButtonGroup } from "@/components/ui/button";
import StatCard from "~/components/global/StatCard/StatCard.vue";
import BaseCard from "@/components/Base/Card.vue";
import BaseSectionHeader from "@/components/Base/SectionHeader.vue";
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 api = useUserApi();
const { t } = useI18n();
const confirm = useConfirm();
const { openDialog } = useDialog();
const props = defineProps({
currentItemId: {
type: String,
default: undefined,
},
});
const { data: maintenanceDataList, refresh: refreshList } = useAsyncData(
async () => {
const { data } =
props.currentItemId !== undefined
? await api.items.maintenance.getLog(props.currentItemId, { status: maintenanceFilterStatus.value })
: await api.maintenance.getAll({ status: maintenanceFilterStatus.value });
console.log(data);
return data;
},
{
watch: [maintenanceFilterStatus],
}
);
const stats = computed(() => {
console.log(maintenanceDataList);
if (!maintenanceDataList.value) return [];
const count = maintenanceDataList.value ? maintenanceDataList.value.length || 0 : 0;
let total = 0;
maintenanceDataList.value.forEach(item => {
total += parseFloat(item.cost);
});
const average = count > 0 ? total / count : 0;
return [
{
id: "count",
title: t("maintenance.total_entries"),
value: count,
type: "number" as StatsFormat,
},
{
id: "total",
title: t("maintenance.total_cost"),
value: total,
type: "currency" as StatsFormat,
},
{
id: "average",
title: t("maintenance.monthly_average"),
value: average,
type: "currency" as StatsFormat,
},
];
});
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>
<section class="space-y-6">
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<StatCard v-for="stat in stats" :key="stat.id" :title="stat.title" :value="stat.value" :type="stat.type" />
</div>
<div class="flex">
<ButtonGroup>
<Button
size="sm"
:variant="
maintenanceFilterStatus == MaintenanceFilterStatus.MaintenanceFilterStatusScheduled ? 'default' : 'outline'
"
@click="maintenanceFilterStatus = MaintenanceFilterStatus.MaintenanceFilterStatusScheduled"
>
{{ $t("maintenance.filter.scheduled") }}
</Button>
<Button
size="sm"
:variant="
maintenanceFilterStatus == MaintenanceFilterStatus.MaintenanceFilterStatusCompleted ? 'default' : 'outline'
"
@click="maintenanceFilterStatus = MaintenanceFilterStatus.MaintenanceFilterStatusCompleted"
>
{{ $t("maintenance.filter.completed") }}
</Button>
<Button
size="sm"
:variant="
maintenanceFilterStatus == MaintenanceFilterStatus.MaintenanceFilterStatusBoth ? 'default' : 'outline'
"
@click="maintenanceFilterStatus = MaintenanceFilterStatus.MaintenanceFilterStatusBoth"
>
{{ $t("maintenance.filter.both") }}
</Button>
</ButtonGroup>
<Button
v-if="props.currentItemId"
class="ml-auto"
size="sm"
@click="
openDialog(DialogID.EditMaintenance, {
params: { type: 'create', itemId: props.currentItemId },
onClose: result => {
if (result) {
refreshList();
}
},
})
"
>
<MdiPlus />
{{ $t("maintenance.list.new") }}
</Button>
</div>
</section>
<section>
<!-- begin -->
<MaintenanceEditModal ref="maintenanceEditModal" @changed="refreshList" />
<div class="container space-y-6">
<BaseCard v-for="e in maintenanceDataList" :key="e.id">
<BaseSectionHeader class="border-b p-6">
<span class="mb-2">
<span v-if="!props.currentItemId">
<NuxtLink class="hover:underline" :to="`/item/${(e as MaintenanceEntryWithDetails).itemID}/maintenance`">
{{ (e as MaintenanceEntryWithDetails).itemName }}
</NuxtLink>
-
</span>
{{ e.name }}
</span>
<template #description>
<div class="flex flex-wrap gap-2">
<Badge v-if="validDate(e.completedDate)" variant="outline">
<MdiCheck class="mr-2" />
<DateTime :date="e.completedDate" format="human" datetime-type="date" />
</Badge>
<Badge v-else-if="validDate(e.scheduledDate)" variant="outline">
<MdiCalendar class="mr-2" />
<DateTime :date="e.scheduledDate" format="human" datetime-type="date" />
</Badge>
<TooltipProvider :delay-duration="0">
<Tooltip>
<TooltipTrigger>
<Badge>
<Currency :amount="e.cost" />
</Badge>
</TooltipTrigger>
<TooltipContent> Cost </TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</template>
</BaseSectionHeader>
<div :class="{ 'p-6': e.description }">
<Markdown :source="e.description" />
</div>
<ButtonGroup class="flex flex-wrap justify-end p-4">
<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="completeEntry(e)">
<MdiCheck />
{{ $t("maintenance.list.complete") }}
</Button>
<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="deleteEntry(e.id)">
<MdiDelete />
{{ $t("maintenance.list.delete") }}
</Button>
</ButtonGroup>
</BaseCard>
<div v-if="props.currentItemId" class="hidden first:block">
<button
type="button"
class="relative block w-full rounded-lg border-2 border-dashed p-12 text-center"
@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>
</button>
</div>
</div>
</section>
</template>