feat: QR Codes (#226)

* code gen updates

* qrcode support

* remove opacity on toast

* update item view to use tab-like pages

* adjust view for cards

* fix old API calls for ioutils

* move embed

* extract QR code

* add docs for QR codes

* add QR code
This commit is contained in:
Hayden 2023-01-18 20:44:06 -09:00 committed by GitHub
parent f532b39c46
commit c19fe94c08
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
88 changed files with 3151 additions and 6454 deletions

View file

@ -4,7 +4,7 @@
<div
v-for="(notify, index) in notifications.slice(0, 4)"
:key="notify.id"
class="my-2 w-[300px] rounded-md p-3 text-sm text-white opacity-75"
class="my-2 w-[300px] rounded-md p-3 text-sm text-white"
:class="{
'bg-primary': notify.type === 'info',
'bg-red-600': notify.type === 'error',

View file

@ -1,7 +1,7 @@
<template>
<NuxtLink class="group card rounded-md" :to="`/item/${item.id}`">
<div class="rounded-t flex flex-col bg-neutral text-neutral-content p-5">
<h2 class="text-base mb-4 last:mb-0 font-bold two-line min-h-[48px]">{{ item.name }}</h2>
<div class="rounded-t flex flex-col justify-center bg-neutral text-neutral-content p-5">
<h2 class="text-base mb-2 last:mb-0 font-bold two-line">{{ item.name }}</h2>
<NuxtLink
v-if="item.location"
class="inline-flex text-sm items-center hover:link"

View file

@ -0,0 +1,27 @@
<template>
<div class="dropdown dropdown-left">
<slot>
<label tabindex="0" class="btn btn-circle btn-sm">
<Icon name="mdi-qrcode" />
</label>
</slot>
<div tabindex="0" class="card compact dropdown-content shadow-lg bg-base-100 rounded-box w-64">
<div class="card-body">
<h2 class="text-center">Page URL</h2>
<img :src="getQRCodeUrl()" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
const api = useUserApi();
function getQRCodeUrl(): string {
const currentURL = window.location.href;
return `/api/v1/qrcode?data=${encodeURIComponent(currentURL)}&access_token=${api.items.attachmentToken}`;
}
</script>
<style lang="scss" scoped></style>

View file

@ -96,7 +96,7 @@ export interface ItemOut {
/** @example "0" */
purchasePrice: string;
/** Purchase */
purchaseTime: Date;
purchaseTime: string;
quantity: number;
serialNumber: string;
soldNotes: string;
@ -148,7 +148,7 @@ export interface ItemUpdate {
/** @example "0" */
purchasePrice: string;
/** Purchase */
purchaseTime: Date;
purchaseTime: string;
quantity: number;
/** Identifications */
serialNumber: string;
@ -228,7 +228,7 @@ export interface LocationUpdate {
export interface MaintenanceEntry {
/** @example "0" */
cost: string;
date: Date;
date: string;
description: string;
id: string;
name: string;
@ -237,7 +237,7 @@ export interface MaintenanceEntry {
export interface MaintenanceEntryCreate {
/** @example "0" */
cost: string;
date: Date;
date: string;
description: string;
name: string;
}
@ -245,7 +245,7 @@ export interface MaintenanceEntryCreate {
export interface MaintenanceEntryUpdate {
/** @example "0" */
cost: string;
date: Date;
date: string;
description: string;
name: string;
}
@ -257,7 +257,7 @@ export interface MaintenanceLog {
itemId: string;
}
export interface PaginationResultRepoItemSummary {
export interface PaginationResultItemSummary {
items: ItemSummary[];
page: number;
pageSize: number;
@ -294,7 +294,7 @@ export interface ValueOverTime {
}
export interface ValueOverTimeEntry {
date: Date;
date: string;
name: string;
value: number;
}
@ -347,13 +347,13 @@ export interface EnsureAssetIDResult {
}
export interface GroupInvitation {
expiresAt: Date;
expiresAt: string;
token: string;
uses: number;
}
export interface GroupInvitationCreate {
expiresAt: Date;
expiresAt: string;
uses: number;
}
@ -363,6 +363,6 @@ export interface ItemAttachmentToken {
export interface TokenResponse {
attachmentToken: string;
expiresAt: Date;
expiresAt: string;
token: string;
}

View file

@ -30,6 +30,15 @@
refresh();
});
const lastRoute = ref(route.fullPath);
watchEffect(() => {
if (lastRoute.value.endsWith("edit")) {
refresh();
}
lastRoute.value = route.fullPath;
});
type FilteredAttachments = {
attachments: ItemAttachment[];
warranty: ItemAttachment[];
@ -325,6 +334,30 @@
onClickOutside(refDialogBody, () => {
closeDialog();
});
const currentPath = computed(() => {
return route.path;
});
const tabs = computed(() => {
return [
{
id: "details",
name: "Details",
to: `/item/${itemId.value}`,
},
{
id: "log",
name: "Log",
to: `/item/${itemId.value}/log`,
},
{
id: "edit",
name: "Edit",
to: `/item/${itemId.value}/edit`,
},
];
});
</script>
<template>
@ -343,66 +376,66 @@
<img class="max-w-[80vw] max-h-[80vh]" :src="dialoged.src" />
</div>
</dialog>
<section>
<BaseSectionHeader>
<Icon name="mdi-package-variant" class="mr-2 -mt-1 text-base-content" />
<span class="text-base-content">
{{ item ? item.name : "" }}
</span>
<div v-if="item.parent" class="text-sm breadcrumbs pb-0">
<ul class="text-base-content/70">
<li>
<NuxtLink :to="`/item/${item.parent.id}`"> {{ item.parent.name }}</NuxtLink>
</li>
<li>{{ item.name }}</li>
</ul>
</div>
<template #description>
<div class="flex flex-wrap gap-2 mt-3">
<NuxtLink ref="badge" class="badge p-3" :to="`/location/${item.location.id}`">
<Icon name="heroicons-map-pin" class="mr-2 swap-on"></Icon>
{{ item.location.name }}
</NuxtLink>
<template v-if="item.labels && item.labels.length > 0">
<LabelChip v-for="label in item.labels" :key="label.id" class="badge-primary" :label="label" />
</template>
</div>
</template>
</BaseSectionHeader>
<div class="flex flex-wrap items-center justify-between mb-6">
<div class="tabs">
<NuxtLink
v-for="t in tabs"
:key="t.id"
:to="t.to"
class="tab tab-bordered lg:tab-lg"
:class="`${t.to === currentPath ? 'tab-active' : ''}`"
>
{{ t.name }}
</NuxtLink>
</div>
<BaseButton class="btn btn-sm" @click="deleteItem()">
<Icon name="mdi-delete" class="mr-2" />
Delete
</BaseButton>
</div>
</section>
<section>
<div class="space-y-6">
<BaseCard>
<template #title>
<BaseSectionHeader>
<Icon name="mdi-package-variant" class="mr-2 -mt-1 text-base-content" />
<span class="text-base-content">
{{ item ? item.name : "" }}
</span>
<div v-if="item.parent" class="text-sm breadcrumbs pb-0">
<ul class="text-base-content/70">
<li>
<NuxtLink :to="`/item/${item.parent.id}`"> {{ item.parent.name }}</NuxtLink>
</li>
<li>{{ item.name }}</li>
</ul>
</div>
<template #description>
<div class="flex flex-wrap gap-2 mt-3">
<NuxtLink ref="badge" class="badge p-3" :to="`/location/${item.location.id}`">
<Icon name="heroicons-map-pin" class="mr-2 swap-on"></Icon>
{{ item.location.name }}
</NuxtLink>
<template v-if="item.labels && item.labels.length > 0">
<LabelChip v-for="label in item.labels" :key="label.id" class="badge-primary" :label="label" />
</template>
</div>
</template>
</BaseSectionHeader>
</template>
<BaseCard v-if="!hasNested">
<template #title> Details </template>
<template #title-actions>
<div class="flex flex-wrap justify-between items-center mt-2 gap-4">
<label v-if="!hasNested" class="label cursor-pointer">
<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>
<div class="flex flex-wrap justify-end gap-2 ml-auto">
<BaseButton size="sm" :to="`/item/${itemId}/edit`">
<template #icon>
<Icon name="mdi-pencil" />
</template>
Edit
</BaseButton>
<BaseButton size="sm" @click="deleteItem">
<template #icon>
<Icon name="mdi-delete" />
</template>
Delete
</BaseButton>
<BaseButton size="sm" :to="`/item/${itemId}/log`">
<template #icon>
<Icon name="mdi-post" />
</template>
Log
</BaseButton>
</div>
<PageQRCode />
</div>
</template>
<DetailsSection v-if="!hasNested" :details="itemDetails" />
<DetailsSection :details="itemDetails" />
</BaseCard>
<NuxtPage :item="item" :page-key="itemId" />

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ItemAttachment, ItemUpdate } from "~~/lib/api/types/data-contracts";
import { ItemAttachment, ItemField, ItemUpdate } from "~~/lib/api/types/data-contracts";
import { AttachmentTypes } from "~~/lib/api/types/non-generated";
import { useLabelStore } from "~~/stores/labels";
import { useLocationStore } from "~~/stores/locations";
@ -196,11 +196,16 @@
function uploadImage(e: InputEvent) {
const files = (e.target as HTMLInputElement).files;
if (!files) {
if (!files || !files.item(0)) {
return;
}
uploadAttachment([files.item(0)], AttachmentTypes.Attachment);
const first = files.item(0);
if (!first) {
return;
}
uploadAttachment([first], AttachmentTypes.Attachment);
}
const dropPhoto = (files: File[] | null) => uploadAttachment(files, AttachmentTypes.Photo);
@ -210,7 +215,7 @@
const dropReceipt = (files: File[] | null) => uploadAttachment(files, AttachmentTypes.Receipt);
async function uploadAttachment(files: File[] | null, type: AttachmentTypes) {
if (!files && files.length === 0) {
if (!files || files.length === 0) {
return;
}
@ -295,22 +300,6 @@
toast.success("Attachment updated");
}
// Custom Fields
// const fieldTypes = [
// {
// name: "Text",
// value: "text",
// },
// {
// name: "Number",
// value: "number",
// },
// {
// name: "Boolean",
// value: "boolean",
// },
// ];
function addField() {
item.value.fields.push({
id: null,
@ -320,7 +309,7 @@
numberValue: 0,
booleanValue: false,
timeValue: null,
});
} as unknown as ItemField);
}
const { query, results } = useItemSearch(api, { immediate: false });
@ -328,7 +317,7 @@
</script>
<template>
<BaseContainer v-if="item" class="pb-8">
<div v-if="item" class="pb-8">
<BaseModal v-model="editState.modal">
<template #title> Attachment Edit </template>
@ -346,15 +335,11 @@
</div>
</BaseModal>
<section class="px-3">
<div class="space-y-4">
<section>
<div class="space-y-6">
<div class="card bg-base-100 shadow-xl sm:rounded-lg overflow-visible">
<BaseSectionHeader v-if="item" class="p-5">
<Icon name="mdi-package-variant" class="-mt-1 mr-2 text-base-content" />
<span class="text-base-content">
{{ item.name }}
</span>
<p class="text-sm text-base-content font-bold pb-0 mb-0">Quantity {{ item.quantity }}</p>
<span class="text-base-content"> Edit </span>
<template #after>
<div class="modal-action mt-3">
<div class="mr-auto tooltip" data-tip="Hide the cruft! ">
@ -632,5 +617,5 @@
</div>
</div>
</section>
</BaseContainer>
</div>
</template>

View file

@ -113,20 +113,6 @@
</BaseModal>
<section class="space-y-6">
<div class="flex">
<BaseButton size="sm" @click="$router.go(-1)">
<template #icon>
<Icon name="mdi-arrow-left" class="h-5 w-5" />
</template>
Back
</BaseButton>
<BaseButton class="ml-auto" size="sm" @click="newEntry()">
<template #icon>
<Icon name="mdi-post" />
</template>
Log Maintenance
</BaseButton>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<StatCard
v-for="stat in stats"
@ -137,6 +123,14 @@
:type="stat.type"
/>
</div>
<div class="flex">
<BaseButton class="ml-auto" size="sm" @click="newEntry()">
<template #icon>
<Icon name="mdi-post" />
</template>
Log Maintenance
</BaseButton>
</div>
<div class="container space-y-6">
<BaseCard v-for="e in log.entries" :key="e.id">
<BaseSectionHeader class="p-6 border-b border-b-gray-300">

View file

@ -149,6 +149,7 @@
<Icon class="mr-1" name="mdi-delete" />
Delete
</BaseButton>
<PageQRCode />
</div>
</template>

View file

@ -172,6 +172,7 @@
<Icon class="mr-1" name="mdi-delete" />
Delete
</BaseButton>
<PageQRCode />
</div>
</template>