mirror of
https://github.com/hay-kot/homebox.git
synced 2024-11-25 10:05:43 +00:00
f91b33db38
* change all icons to use iconify * fix minor UI elements * fix layout of table
631 lines
16 KiB
Vue
631 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import { AnyDetail, Detail, Details, filterZeroValues } from "~~/components/global/DetailsSection/types";
|
|
import { ItemAttachment } from "~~/lib/api/types/data-contracts";
|
|
import MdiClose from "~icons/mdi/close";
|
|
import MdiPackageVariant from "~icons/mdi/package-variant";
|
|
import MdiPlus from "~icons/mdi/plus";
|
|
import MdiMinus from "~icons/mdi/minus";
|
|
import MdiDownload from "~icons/mdi/download";
|
|
|
|
definePageMeta({
|
|
middleware: ["auth"],
|
|
});
|
|
|
|
const route = useRoute();
|
|
const api = useUserApi();
|
|
const toast = useNotifier();
|
|
|
|
const itemId = computed<string>(() => route.params.id as string);
|
|
const preferences = useViewPreferences();
|
|
|
|
const hasNested = computed<boolean>(() => {
|
|
return route.fullPath.split("/").at(-1) !== itemId.value;
|
|
});
|
|
|
|
const { data: item, refresh } = useAsyncData(itemId.value, async () => {
|
|
const { data, error } = await api.items.get(itemId.value);
|
|
if (error) {
|
|
toast.error("Failed to load item");
|
|
navigateTo("/home");
|
|
return;
|
|
}
|
|
return data;
|
|
});
|
|
onMounted(() => {
|
|
refresh();
|
|
});
|
|
|
|
const lastRoute = ref(route.fullPath);
|
|
watchEffect(() => {
|
|
if (lastRoute.value.endsWith("edit")) {
|
|
refresh();
|
|
}
|
|
|
|
lastRoute.value = route.fullPath;
|
|
});
|
|
|
|
async function adjustQuantity(amount: number) {
|
|
if (!item.value) {
|
|
return;
|
|
}
|
|
|
|
const newQuantity = item.value.quantity + amount;
|
|
if (newQuantity < 0) {
|
|
toast.error("Quantity cannot be negative");
|
|
return;
|
|
}
|
|
|
|
const resp = await api.items.patch(item.value.id, {
|
|
id: item.value.id,
|
|
quantity: newQuantity,
|
|
});
|
|
|
|
if (resp.error) {
|
|
toast.error("Failed to adjust quantity");
|
|
return;
|
|
}
|
|
|
|
item.value.quantity = newQuantity;
|
|
}
|
|
|
|
type FilteredAttachments = {
|
|
attachments: ItemAttachment[];
|
|
warranty: ItemAttachment[];
|
|
manuals: ItemAttachment[];
|
|
receipts: ItemAttachment[];
|
|
};
|
|
|
|
type Photo = {
|
|
src: string;
|
|
};
|
|
|
|
const photos = computed<Photo[]>(() => {
|
|
return (
|
|
item.value?.attachments.reduce((acc, cur) => {
|
|
if (cur.type === "photo") {
|
|
acc.push({
|
|
// @ts-expect-error - it's impossible for this to be null at this point
|
|
src: api.authURL(`/items/${item.value.id}/attachments/${cur.id}`),
|
|
});
|
|
}
|
|
return acc;
|
|
}, [] as Photo[]) || []
|
|
);
|
|
});
|
|
|
|
const attachments = computed<FilteredAttachments>(() => {
|
|
if (!item.value) {
|
|
return {
|
|
attachments: [],
|
|
manuals: [],
|
|
warranty: [],
|
|
receipts: [],
|
|
};
|
|
}
|
|
|
|
return item.value.attachments.reduce(
|
|
(acc, attachment) => {
|
|
if (attachment.type === "photo") {
|
|
return acc;
|
|
}
|
|
if (attachment.type === "warranty") {
|
|
acc.warranty.push(attachment);
|
|
} else if (attachment.type === "manual") {
|
|
acc.manuals.push(attachment);
|
|
} else if (attachment.type === "receipt") {
|
|
acc.receipts.push(attachment);
|
|
} else {
|
|
acc.attachments.push(attachment);
|
|
}
|
|
return acc;
|
|
},
|
|
{
|
|
attachments: [] as ItemAttachment[],
|
|
warranty: [] as ItemAttachment[],
|
|
manuals: [] as ItemAttachment[],
|
|
receipts: [] as ItemAttachment[],
|
|
}
|
|
);
|
|
});
|
|
|
|
const assetID = computed<Details>(() => {
|
|
if (!item.value) {
|
|
return [];
|
|
}
|
|
|
|
if (item.value?.assetId === "000-000") {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
{
|
|
name: "Asset ID",
|
|
text: item.value?.assetId,
|
|
},
|
|
];
|
|
});
|
|
|
|
const itemDetails = computed<Details>(() => {
|
|
if (!item.value) {
|
|
return [];
|
|
}
|
|
|
|
const ret: Details = [
|
|
{
|
|
name: "Quantity",
|
|
text: item.value?.quantity,
|
|
slot: "quantity",
|
|
},
|
|
{
|
|
name: "Serial Number",
|
|
text: item.value?.serialNumber,
|
|
copyable: true,
|
|
},
|
|
{
|
|
name: "Model Number",
|
|
text: item.value?.modelNumber,
|
|
copyable: true,
|
|
},
|
|
{
|
|
name: "Manufacturer",
|
|
text: item.value?.manufacturer,
|
|
copyable: true,
|
|
},
|
|
{
|
|
name: "Insured",
|
|
text: item.value?.insured ? "Yes" : "No",
|
|
},
|
|
{
|
|
name: "Notes",
|
|
type: "markdown",
|
|
text: item.value?.notes,
|
|
},
|
|
...assetID.value,
|
|
...item.value.fields.map(field => {
|
|
/**
|
|
* Support Special URL Syntax
|
|
*/
|
|
const url = maybeUrl(field.textValue);
|
|
if (url.isUrl) {
|
|
return {
|
|
type: "link",
|
|
name: field.name,
|
|
text: url.text,
|
|
href: url.url,
|
|
} as AnyDetail;
|
|
}
|
|
|
|
return {
|
|
name: field.name,
|
|
text: field.textValue,
|
|
};
|
|
}),
|
|
];
|
|
|
|
if (!preferences.value.showEmpty) {
|
|
return filterZeroValues(ret);
|
|
}
|
|
|
|
return ret;
|
|
});
|
|
|
|
const showAttachments = computed(() => {
|
|
if (preferences.value?.showEmpty) {
|
|
return true;
|
|
}
|
|
|
|
return (
|
|
attachments.value.attachments.length > 0 ||
|
|
attachments.value.warranty.length > 0 ||
|
|
attachments.value.manuals.length > 0 ||
|
|
attachments.value.receipts.length > 0
|
|
);
|
|
});
|
|
|
|
const attachmentDetails = computed(() => {
|
|
const details: Detail[] = [];
|
|
|
|
const push = (name: string) => {
|
|
details.push({
|
|
name,
|
|
text: "",
|
|
slot: name.toLowerCase(),
|
|
});
|
|
};
|
|
|
|
if (attachments.value.attachments.length > 0) {
|
|
push("Attachments");
|
|
}
|
|
|
|
if (attachments.value.warranty.length > 0) {
|
|
push("Warranty");
|
|
}
|
|
|
|
if (attachments.value.manuals.length > 0) {
|
|
push("Manuals");
|
|
}
|
|
|
|
if (attachments.value.receipts.length > 0) {
|
|
push("Receipts");
|
|
}
|
|
|
|
return details;
|
|
});
|
|
|
|
const showWarranty = computed(() => {
|
|
if (preferences.value.showEmpty) {
|
|
return true;
|
|
}
|
|
return validDate(item.value?.warrantyExpires);
|
|
});
|
|
|
|
const warrantyDetails = computed(() => {
|
|
const details: Details = [
|
|
{
|
|
name: "Lifetime Warranty",
|
|
text: item.value?.lifetimeWarranty ? "Yes" : "No",
|
|
},
|
|
];
|
|
|
|
if (item.value?.lifetimeWarranty) {
|
|
details.push({
|
|
name: "Warranty Expires",
|
|
text: "N/A",
|
|
});
|
|
} else {
|
|
details.push({
|
|
name: "Warranty Expires",
|
|
text: item.value?.warrantyExpires || "",
|
|
type: "date",
|
|
date: true,
|
|
});
|
|
}
|
|
|
|
details.push({
|
|
name: "Warranty Details",
|
|
type: "markdown",
|
|
text: item.value?.warrantyDetails || "",
|
|
});
|
|
|
|
if (!preferences.value.showEmpty) {
|
|
return filterZeroValues(details);
|
|
}
|
|
|
|
return details;
|
|
});
|
|
|
|
const showPurchase = computed(() => {
|
|
if (preferences.value.showEmpty) {
|
|
return true;
|
|
}
|
|
return item.value?.purchaseFrom || item.value?.purchasePrice !== "0";
|
|
});
|
|
|
|
const purchaseDetails = computed<Details>(() => {
|
|
const v: Details = [
|
|
{
|
|
name: "Purchased From",
|
|
text: item.value?.purchaseFrom || "",
|
|
},
|
|
{
|
|
name: "Purchase Price",
|
|
text: item.value?.purchasePrice || "",
|
|
type: "currency",
|
|
},
|
|
{
|
|
name: "Purchase Date",
|
|
text: item.value?.purchaseTime || "",
|
|
type: "date",
|
|
date: true,
|
|
},
|
|
];
|
|
|
|
if (!preferences.value.showEmpty) {
|
|
return filterZeroValues(v);
|
|
}
|
|
|
|
return v;
|
|
});
|
|
|
|
const showSold = computed(() => {
|
|
if (preferences.value.showEmpty) {
|
|
return true;
|
|
}
|
|
return item.value?.soldTo || item.value?.soldPrice !== "0";
|
|
});
|
|
|
|
const soldDetails = computed<Details>(() => {
|
|
const v: Details = [
|
|
{
|
|
name: "Sold To",
|
|
text: item.value?.soldTo || "",
|
|
},
|
|
{
|
|
name: "Sold Price",
|
|
text: item.value?.soldPrice || "",
|
|
type: "currency",
|
|
},
|
|
{
|
|
name: "Sold At",
|
|
text: item.value?.soldTime || "",
|
|
type: "date",
|
|
date: true,
|
|
},
|
|
];
|
|
|
|
if (!preferences.value.showEmpty) {
|
|
return filterZeroValues(v);
|
|
}
|
|
|
|
return v;
|
|
});
|
|
|
|
const refDialog = ref<HTMLDialogElement>();
|
|
const dialoged = reactive({
|
|
src: "",
|
|
});
|
|
|
|
function openDialog(img: Photo) {
|
|
// @ts-ignore - I don't know why this is happening
|
|
refDialog.value?.showModal();
|
|
dialoged.src = img.src;
|
|
}
|
|
|
|
function closeDialog() {
|
|
// @ts-ignore - I don't know why this is happening
|
|
refDialog.value?.close();
|
|
}
|
|
|
|
const refDialogBody = ref<HTMLDivElement>();
|
|
onClickOutside(refDialogBody, () => {
|
|
closeDialog();
|
|
});
|
|
|
|
const currentPath = computed(() => {
|
|
return route.path;
|
|
});
|
|
|
|
const tabs = computed(() => {
|
|
return [
|
|
{
|
|
id: "details",
|
|
name: "Details",
|
|
to: `/item/${itemId.value}`,
|
|
},
|
|
{
|
|
id: "log",
|
|
name: "Maintenance",
|
|
to: `/item/${itemId.value}/maintenance`,
|
|
},
|
|
{
|
|
id: "edit",
|
|
name: "Edit",
|
|
to: `/item/${itemId.value}/edit`,
|
|
},
|
|
];
|
|
});
|
|
|
|
const fullpath = computedAsync(async () => {
|
|
if (!item.value) {
|
|
return [];
|
|
}
|
|
|
|
const resp = await api.items.fullpath(item.value.id);
|
|
if (resp.error) {
|
|
toast.error("Failed to load item");
|
|
return [];
|
|
}
|
|
|
|
return resp.data;
|
|
});
|
|
|
|
const items = computedAsync(async () => {
|
|
if (!item.value) {
|
|
return [];
|
|
}
|
|
|
|
const resp = await api.items.getAll({
|
|
parentIds: [item.value.id],
|
|
});
|
|
|
|
if (resp.error) {
|
|
toast.error("Failed to load items");
|
|
return [];
|
|
}
|
|
|
|
return resp.data.items;
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<BaseContainer v-if="item" class="pb-8">
|
|
<Title>{{ item.name }}</Title>
|
|
<dialog ref="refDialog" class="z-[999] fixed bg-transparent">
|
|
<div ref="refDialogBody" class="relative">
|
|
<div class="absolute right-0 -mt-3 -mr-3 sm:-mt-4 sm:-mr-4 space-x-1">
|
|
<a class="btn btn-sm sm:btn-md btn-primary btn-circle" :href="dialoged.src" download>
|
|
<MdiDownload class="h-5 w-5" />
|
|
</a>
|
|
<button class="btn btn-sm sm:btn-md btn-primary btn-circle" @click="closeDialog()">
|
|
<MdiClose class="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<img class="max-w-[80vw] max-h-[80vh]" :src="dialoged.src" />
|
|
</div>
|
|
</dialog>
|
|
|
|
<section>
|
|
<div class="bg-base-100 rounded p-3">
|
|
<header class="mb-2">
|
|
<div class="flex flex-wrap items-end gap-2">
|
|
<div class="avatar placeholder mb-auto">
|
|
<div class="bg-neutral-focus text-neutral-content rounded-full w-12">
|
|
<MdiPackageVariant class="h-7 w-7" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div v-if="fullpath && fullpath.length > 0" class="text-sm breadcrumbs pt-0 pb-0">
|
|
<ul class="text-base-content/70">
|
|
<li v-for="part in fullpath" :key="part.id">
|
|
<NuxtLink :to="`/${part.type}/${part.id}`"> {{ part.name }}</NuxtLink>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<h1 class="text-2xl pb-1">
|
|
{{ item ? item.name : "" }}
|
|
</h1>
|
|
<div class="flex gap-1 flex-wrap text-xs">
|
|
<div>
|
|
Created
|
|
<DateTime :date="item?.createdAt" />
|
|
</div>
|
|
-
|
|
<div>
|
|
Updated
|
|
<DateTime :date="item?.updatedAt" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
<div class="divider my-0 mb-1"></div>
|
|
<div class="p-1 prose max-w-[100%]">
|
|
<Markdown v-if="item && item.description" class="text-base" :source="item.description"> </Markdown>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap items-center justify-between mb-6 mt-3">
|
|
<div class="btn-group">
|
|
<NuxtLink
|
|
v-for="t in tabs"
|
|
:key="t.id"
|
|
:to="t.to"
|
|
class="btn btn-sm"
|
|
:class="`${t.to === currentPath ? 'btn-active' : ''}`"
|
|
>
|
|
{{ t.name }}
|
|
</NuxtLink>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<div class="space-y-6">
|
|
<BaseCard v-if="!hasNested" collapsable>
|
|
<template #title> Details </template>
|
|
<template #title-actions>
|
|
<div class="flex flex-wrap justify-between items-center mt-2 gap-4">
|
|
<label class="label cursor-pointer">
|
|
<input v-model="preferences.showEmpty" type="checkbox" class="toggle toggle-primary" />
|
|
<span class="label-text ml-4"> Show Empty </span>
|
|
</label>
|
|
<PageQRCode />
|
|
</div>
|
|
</template>
|
|
<DetailsSection :details="itemDetails">
|
|
<template #quantity="{ detail }">
|
|
{{ detail.text }}
|
|
<span
|
|
class="opacity-0 group-hover:opacity-100 ml-4 my-0 duration-75 transition-opacity inline-flex gap-2"
|
|
>
|
|
<button class="btn btn-circle btn-xs" @click="adjustQuantity(-1)">
|
|
<MdiMinus class="h-3 w-3" />
|
|
</button>
|
|
<button class="btn btn-circle btn-xs" @click="adjustQuantity(1)">
|
|
<MdiPlus class="h-3 w-3" />
|
|
</button>
|
|
</span>
|
|
</template>
|
|
</DetailsSection>
|
|
</BaseCard>
|
|
|
|
<NuxtPage :item="item" :page-key="itemId" />
|
|
<template v-if="!hasNested">
|
|
<BaseCard v-if="photos && photos.length > 0">
|
|
<template #title> Photos </template>
|
|
<div
|
|
class="container border-t border-gray-300 p-4 flex flex-wrap gap-2 mx-auto max-h-[500px] overflow-y-scroll scroll-bg"
|
|
>
|
|
<button v-for="(img, i) in photos" :key="i" @click="openDialog(img)">
|
|
<img class="rounded max-h-[200px]" :src="img.src" />
|
|
</button>
|
|
</div>
|
|
</BaseCard>
|
|
|
|
<BaseCard v-if="showAttachments" collapsable>
|
|
<template #title> Attachments </template>
|
|
<DetailsSection v-if="attachmentDetails.length > 0" :details="attachmentDetails">
|
|
<template #manuals>
|
|
<ItemAttachmentsList
|
|
v-if="attachments.manuals.length > 0"
|
|
:attachments="attachments.manuals"
|
|
:item-id="item.id"
|
|
/>
|
|
</template>
|
|
<template #attachments>
|
|
<ItemAttachmentsList
|
|
v-if="attachments.attachments.length > 0"
|
|
:attachments="attachments.attachments"
|
|
:item-id="item.id"
|
|
/>
|
|
</template>
|
|
<template #warranty>
|
|
<ItemAttachmentsList
|
|
v-if="attachments.warranty.length > 0"
|
|
:attachments="attachments.warranty"
|
|
:item-id="item.id"
|
|
/>
|
|
</template>
|
|
<template #receipts>
|
|
<ItemAttachmentsList
|
|
v-if="attachments.receipts.length > 0"
|
|
:attachments="attachments.receipts"
|
|
:item-id="item.id"
|
|
/>
|
|
</template>
|
|
</DetailsSection>
|
|
<div v-else>
|
|
<p class="text-base-content/70 px-6 pb-4">No attachments found</p>
|
|
</div>
|
|
</BaseCard>
|
|
|
|
<BaseCard v-if="showPurchase" collapsable>
|
|
<template #title> Purchase Details </template>
|
|
<DetailsSection :details="purchaseDetails" />
|
|
</BaseCard>
|
|
|
|
<BaseCard v-if="showWarranty" collapsable>
|
|
<template #title> Warranty Details </template>
|
|
<DetailsSection :details="warrantyDetails" />
|
|
</BaseCard>
|
|
|
|
<BaseCard v-if="showSold" collapsable>
|
|
<template #title> Sold Details </template>
|
|
<DetailsSection :details="soldDetails" />
|
|
</BaseCard>
|
|
</template>
|
|
</div>
|
|
</section>
|
|
|
|
<section v-if="items && items.length > 0" class="my-6">
|
|
<ItemViewSelectable :items="items" />
|
|
</section>
|
|
</BaseContainer>
|
|
</template>
|
|
|
|
<style lang="css" scoped>
|
|
/* Style dialog background */
|
|
dialog::backdrop {
|
|
background: rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
.scroll-bg::-webkit-scrollbar {
|
|
width: 0.5rem;
|
|
}
|
|
|
|
.scroll-bg::-webkit-scrollbar-thumb {
|
|
border-radius: 0.25rem;
|
|
@apply bg-base-300;
|
|
}
|
|
</style>
|