mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-24 06:28:34 +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
170 lines
5.6 KiB
Vue
170 lines
5.6 KiB
Vue
<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>
|