feat: items-editor (#5)

* format readme

* update logo

* format html

* add logo to docs

* repository for document and document tokens

* add attachments type and repository

* autogenerate types via scripts

* use autogenerated types

* attachment type updates

* add insured and quantity fields for items

* implement HasID interface for entities

* implement label updates for items

* implement service update method

* WIP item update client side actions

* check err on attachment

* finish types for basic items editor

* remove unused var

* house keeping
This commit is contained in:
Hayden 2022-09-12 14:47:27 -08:00 committed by GitHub
parent fbc364dcd2
commit 95ab14b866
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
125 changed files with 15626 additions and 1791 deletions

8
frontend/.gitignore vendored
View file

@ -1,8 +0,0 @@
node_modules
*.log*
.nuxt
.nitro
.cache
.output
.env
dist

View file

@ -1,42 +0,0 @@
# Nuxt 3 Minimal Starter
Look at the [nuxt 3 documentation](https://v3.nuxtjs.org) to learn more.
## Setup
Make sure to install the dependencies:
```bash
# yarn
yarn install
# npm
npm install
# pnpm
pnpm install --shamefully-hoist
```
## Development Server
Start the development server on http://localhost:3000
```bash
npm run dev
```
## Production
Build the application for production:
```bash
npm run build
```
Locally preview production build:
```bash
npm run preview
```
Checkout the [deployment documentation](https://v3.nuxtjs.org/guide/deploy/presets) for more information.

View file

@ -70,7 +70,7 @@
<BaseContainer>
<h2 class="mt-1 text-4xl font-bold tracking-tight text-neutral-content sm:text-5xl lg:text-6xl flex">
HomeB
<AppLogo class="w-12 -mb-4" style="padding-left: 3px; padding-right: 2px" />
<AppLogo class="w-12 -mb-4" />
x
</h2>
<div class="ml-1 mt-2 text-lg text-neutral-content/75 space-x-2">

View file

@ -1,123 +1,47 @@
<template>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 596.5055138004384 585.369487986598">
<g
stroke-linecap="round"
transform="translate(437.568672588907 210.93877417794465) rotate(332.3235338946895 66.970006481548 27.559467997664797)"
>
<path
d="M-0.3 -0.89 L131.27 -1.27 L131.05 52.32 L-2.89 53.3"
stroke="none"
stroke-width="0"
fill="#15aabf"
></path>
<path
d="M1.61 2.92 C34.39 0.43, 67.49 -3.76, 136.43 -0.16 M-1.81 1.81 C54.26 1.13, 105.28 -0.86, 133.28 -0.04 M132.66 3.06 C133.92 12.97, 132.16 31.97, 132.92 51.2 M134.28 1.16 C131.72 11.69, 133.56 23.47, 134.52 54.65 M133.93 53.09 C103.49 59.22, 80.28 58.51, -1.19 52.09 M133.03 54.88 C92.08 50.88, 53.71 52.46, -0.4 54.88 M-3.64 53.12 C-1.65 33.33, 3.49 15.58, -2.23 -1.93 M-1.08 54.38 C0.63 44.74, -0.42 33.82, 0.29 1.34"
stroke="#000"
stroke-width="2"
fill="none"
></path>
</g>
<g stroke-linecap="round">
<g transform="translate(308.4481755172761 281.2115533662909) rotate(0 1.1385609918289674 145.9953857867422)">
<path
d="M-1.01 -2.17 C-1.07 46.71, 0.3 244.44, -0.46 294.16 M3.63 2.83 C3.44 50.71, -0.72 241.22, -1.36 289.41"
stroke="#000000"
stroke-width="2"
fill="none"
></path>
</g>
</g>
<g stroke-linecap="round">
<g transform="translate(308.16925883018916 284.66360015581995) rotate(0 135.1525798049602 -68.20042785962323)">
<path
d="M2.47 0.47 C46.8 -21.36, 220.33 -110.19, 264.96 -133.2 M0.37 -1.74 C45.62 -24.11, 225.01 -114.58, 269.93 -136.88"
stroke="#000000"
stroke-width="2"
fill="none"
></path>
</g>
</g>
<g stroke-linecap="round">
<g transform="translate(311.39372316987726 570.9674003164946) rotate(0 136.24116036890297 -67.43777376368234)">
<path
d="M-2.63 2.46 C20.4 -9.48, 94.34 -47.87, 140.63 -71.17 C186.92 -94.47, 252.17 -126.49, 275.11 -137.33 M1.14 1.33 C23.81 -10.35, 94.55 -46.04, 139.83 -68.58 C185.12 -91.11, 249.48 -121.76, 272.86 -133.89"
stroke="#000000"
stroke-width="2"
fill="none"
></path>
</g>
</g>
<g stroke-linecap="round">
<g transform="translate(580.6092831051336 150.72201134532952) rotate(0 1.5678417062894852 142.75141008423634)">
<path
d="M2.66 -1.91 C3.6 45.58, 2.41 239.01, 2.99 287.41 M0.67 3.23 C1.39 51.03, -0.51 235.96, 0.31 282.74"
stroke="#000000"
stroke-width="2"
fill="none"
></path>
</g>
</g>
<g stroke-linecap="round">
<g transform="translate(306.6976102947664 283.14653391715) rotate(0 -140.18354779216435 -59.60806644015338)">
<path
d="M-0.81 0.62 C-48.48 -18.36, -235.46 -96.23, -283.34 -115.75 M3.94 -1.52 C-44.13 -21.12, -236.56 -99.84, -284.31 -119.83"
stroke="#000000"
stroke-width="2"
fill="none"
></path>
</g>
</g>
<g stroke-linecap="round">
<g transform="translate(304.3414324224632 572.5226612839633) rotate(0 -144.27019052747903 -64.7761163684645)">
<path
d="M2.34 1.71 C-46 -19.52, -242.75 -105.04, -290.88 -126.78 M0.17 0.18 C-47.25 -21.98, -237.9 -110.2, -285.78 -131.27"
stroke="#000000"
stroke-width="2"
fill="none"
></path>
</g>
</g>
<g stroke-linecap="round">
<g
transform="translate(15.275892138818847 448.50738095516135) rotate(0 -0.49579063445983707 -143.71703352554232)"
>
<path
d="M-2.4 0.97 C-2.94 -47.38, -0.78 -240.15, -0.9 -288.41 M1.49 -0.97 C0.55 -49.09, -0.95 -237.81, -2.03 -285.33"
stroke="#000000"
stroke-width="2"
fill="none"
></path>
</g>
</g>
<g stroke-linecap="round">
<g transform="translate(10.301143858432795 164.72182108536072) rotate(0 142.35890827057267 -76.26873721417542)">
<path
d="M2.04 -1.02 C49.94 -26.43, 238.14 -126.5, 285.02 -151.52 M-0.3 -4.04 C47.43 -29.16, 234.44 -124.45, 282.68 -148.52"
stroke="#000000"
stroke-width="2"
fill="none"
></path>
</g>
</g>
<g stroke-linecap="round">
<g transform="translate(291.46813332015165 14.258444139957646) rotate(0 143.3244001532809 66.53622476241344)">
<path
d="M-0.18 -1.16 C46.98 21.36, 236.83 111.22, 284.98 134.14 M-3.72 -4.26 C44.15 18.77, 241.76 114.69, 290.37 137.33"
stroke="#000000"
stroke-width="2"
fill="none"
></path>
</g>
</g>
<g stroke-linecap="round">
<g transform="translate(175.60844139934756 81.23280016017816) rotate(0 131.7777041676277 66.73388742398038)">
<path
d="M-1.87 -0.8 C42.4 22.26, 220.78 113.99, 265.42 137.17 M2.32 -3.7 C46.4 18.63, 220.35 109.95, 264.21 132.92"
stroke="#000000"
stroke-width="2"
fill="none"
></path>
</g>
</g>
<svg
viewBox="0 0 10817 9730"
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
style="
fill-rule: evenodd;
clip-rule: evenodd;
stroke-linecap: round;
stroke-linejoin: round;
stroke-miterlimit: 5.42683;
"
>
<path
d="M9310.16 2560.9c245.302 249.894 419.711 539.916 565.373 845.231 47.039 98.872 36.229 215.514-28.2 304.05-64.391 88.536-172.099 134.676-280.631 120.28 0 .053-.039.053-.039.053"
style="fill: gray; stroke: #000; stroke-width: 206.41px"
/>
<path
d="M5401.56 487.044c-127.958 6.227-254.855 40.77-370.992 103.628-765.271 414.225-2397.45 1297.68-3193.03 1728.32-137.966 74.669-250.327 183.605-328.791 313.046l3963.09 2122.43s-249.048 416.428-470.593 786.926c-189.24 316.445-592.833 429.831-919.198 258.219l-2699.36-1419.32v2215.59c0 226.273 128.751 435.33 337.755 548.466 764.649 413.885 2620.97 1418.66 3385.59 1832.51 209.018 113.137 466.496 113.137 675.514 0 764.623-413.857 2620.94-1418.63 3385.59-1832.51 208.989-113.136 337.743-322.193 337.743-548.466v-3513.48c0-318.684-174.59-611.722-454.853-763.409-795.543-430.632-2427.75-1314.09-3193.02-1728.32-141.693-76.684-299.364-111.227-455.442-103.628"
style="fill: #dadada; stroke: #000; stroke-width: 206.42px"
/>
<path
d="M5471.83 4754.46V504.71c-127.958 6.226-325.127 23.1-441.264 85.958-765.271 414.225-2397.45 1297.68-3193.03 1728.32-137.966 74.669-250.327 183.605-328.791 313.046l3963.09 2122.43Z"
style="fill: gray; stroke: #000; stroke-width: 206.42px"
/>
<path
d="m1459.34 2725.96-373.791 715.667c-177.166 339.292-46.417 758 292.375 936.167l4.75 2.5m0 0 2699.37 1419.29c326.374 171.625 729.916 58.25 919.165-258.208 221.542-370.5 470.583-786.917 470.583-786.917l-3963.04-2122.42-2.167 3.458-47.25 90.458"
style="fill: #dadada; stroke: #000; stroke-width: 206.42px"
/>
<path d="M5443.74 520.879v4149.79" style="fill: none; stroke: #000; stroke-width: 153.5px" />
<path
d="M8951.41 4102.72c0-41.65-22.221-80.136-58.291-100.961-36.069-20.825-80.51-20.825-116.58 0l-2439.92 1408.69c-36.07 20.825-58.29 59.311-58.29 100.961V7058c0 41.65 22.22 80.136 58.29 100.961 36.07 20.825 80.51 20.825 116.58 0l2439.92-1408.69c36.07-20.825 58.291-59.312 58.291-100.962v-1546.59Z"
style="fill: #567f67"
/>
<path
d="M8951.41 4102.72c0-41.65-22.221-80.136-58.291-100.961-36.069-20.825-80.51-20.825-116.58 0l-2439.92 1408.69c-36.07 20.825-58.29 59.311-58.29 100.961V7058c0 41.65 22.22 80.136 58.29 100.961 36.07 20.825 80.51 20.825 116.58 0l2439.92-1408.69c36.07-20.825 58.291-59.312 58.291-100.962v-1546.59ZM6463.98 5551.29v1387.06l2301.77-1328.92V4222.37L6463.98 5551.29Z"
/>
<path
d="M5443.76 9041.74v-4278.4"
style="fill: none; stroke: #000; stroke-width: 206.44px; stroke-linejoin: miter"
/>
<path
d="m5471.79 4773.86 3829.35-2188.22"
style="fill: none; stroke: #000; stroke-width: 206.43px; stroke-linejoin: miter"
/>
</svg>
</template>

View file

@ -15,7 +15,7 @@
{{ dKey }}
</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<slot :name="dKey" v-bind="{ key: dKey, value: dValue }">
<slot :name="rmSpace(dKey)" v-bind="{ key: dKey, value: dValue }">
{{ dValue }}
</slot>
</dd>
@ -28,8 +28,13 @@
<script setup lang="ts">
type StringLike = string | number | boolean;
function rmSpace(str: string) {
return str.replace(" ", "");
}
defineProps({
details: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: Object as () => Record<string, StringLike | any>,
required: true,
},

View file

@ -0,0 +1,35 @@
<template>
<div v-if="!inline" class="form-control w-full">
<label class="label cursor-pointer">
<span class="label-text"> {{ label }}</span>
<input v-model="value" type="checkbox" class="checkbox" />
</label>
</div>
<div v-else class="label cursor-pointer sm:grid sm:grid-cols-4 sm:items-start sm:gap-4">
<label>
<span class="label-text">
{{ label }}
</span>
</label>
<input v-model="value" type="checkbox" class="checkbox" />
</div>
</template>
<script setup lang="ts">
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
inline: {
type: Boolean,
default: false,
},
label: {
type: String,
default: "",
},
});
const value = useVModel(props, "modelValue");
</script>

View file

@ -52,9 +52,14 @@
const selected = useVModel(props, "modelValue", emit);
const dateText = computed(() => {
if (!validDate(selected.value)) {
return "";
}
if (selected.value) {
return selected.value.toLocaleDateString();
}
return "";
});
@ -91,9 +96,7 @@
});
function select(e: MouseEvent, day: Date) {
console.log(day);
selected.value = day;
console.log(selected.value);
// @ts-ignore - this is a vue3 bug
e.target.blur();
resetTime();

View file

@ -17,7 +17,7 @@
v-for="(obj, idx) in items"
:key="idx"
:class="{
bordered: selectedIndexes[idx],
bordered: selected[idx],
}"
>
<button type="button" @click="toggle(idx)">
@ -37,10 +37,12 @@
default: "",
},
modelValue: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: Array as () => any[],
default: null,
},
items: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: Array as () => any[],
required: true,
},
@ -54,28 +56,23 @@
},
});
const selectedIndexes = ref<Record<number, boolean>>({});
const value = useVModel(props, "modelValue", emit);
const selected = computed<Record<number, boolean>>(() => {
const obj: Record<number, boolean> = {};
value.value.forEach(itm => {
const idx = props.items.findIndex(item => item[props.name] === itm.name);
obj[idx] = true;
});
return obj;
});
function toggle(index: number) {
selectedIndexes.value[index] = !selectedIndexes.value[index];
const item = props.items[index];
if (selectedIndexes.value[index]) {
value.value = [...value.value, item];
if (selected.value[index]) {
value.value = value.value.filter(itm => itm.name !== item.name);
} else {
value.value = value.value.filter(itm => itm !== item);
value.value = [...value.value, item];
}
}
watchOnce(
() => props.items,
() => {
if (props.selectFirst && props.items.length > 0) {
value.value = props.items[0];
}
}
);
const value = useVModel(props, "modelValue", emit);
</script>

View file

@ -3,9 +3,9 @@
<label class="label">
<span class="label-text">{{ label }}</span>
</label>
<select v-model="value" class="select select-bordered">
<select v-model="selectedIdx" class="select select-bordered">
<option disabled selected>Pick one</option>
<option v-for="obj in items" :key="name != '' ? obj[name] : obj" :value="obj">
<option v-for="(obj, idx) in items" :key="name != '' ? obj[name] : obj" :value="idx">
{{ name != "" ? obj[name] : obj }}
</option>
</select>
@ -24,10 +24,12 @@
default: "",
},
modelValue: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: Object as any,
default: null,
},
items: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: Array as () => any[],
required: true,
},
@ -45,10 +47,16 @@
() => props.items,
() => {
if (props.selectFirst && props.items.length > 0) {
value.value = props.items[0];
selectedIdx.value = 0;
}
}
);
const value = useVModel(props, "modelValue", emit);
const selectedIdx = ref(0);
watch(
() => selectedIdx.value,
() => {
emit("update:modelValue", props.items[selectedIdx.value]);
}
);
</script>

View file

@ -1,9 +1,9 @@
<template>
<div v-if="!inline" class="form-control">
<div v-if="!inline" class="form-control w-full">
<label class="label">
<span class="label-text">{{ label }}</span>
</label>
<textarea v-model="value" class="textarea textarea-bordered h-24" :placeholder="placeholder" />
<textarea ref="el" v-model="value" class="textarea w-full textarea-bordered h-28" :placeholder="placeholder" />
<label v-if="limit" class="label">
<span class="label-text-alt"></span>
<span class="label-text-alt"> {{ valueLen }}/{{ limit }}</span>
@ -14,10 +14,12 @@
<span class="label-text">{{ label }}</span>
</label>
<textarea
ref="el"
v-model="value"
class="textarea textarea-bordered col-span-3 mt-3 h-24"
class="textarea textarea-bordered w-full col-span-3 mt-3 h-28"
auto-grow
:placeholder="placeholder"
auto-height
/>
</div>
</template>
@ -51,6 +53,19 @@
},
});
const el = ref();
function setHeight() {
el.value.style.height = "auto";
el.value.style.height = el.value.scrollHeight + 5 + "px";
}
onUpdated(() => {
console.log("updated");
if (props.inline) {
setHeight();
}
});
const value = useVModel(props, "modelValue", emit);
const valueLen = computed(() => {
return value.value ? value.value.length : 0;

View file

@ -22,11 +22,11 @@
</template>
<script setup lang="ts">
import { Item } from "~~/lib/api/classes/items";
import { ItemOut, ItemSummary } from "~~/lib/api/types/data-contracts";
const props = defineProps({
item: {
type: Object as () => Item,
type: Object as () => ItemOut | ItemSummary,
required: true,
},
});

View file

@ -26,7 +26,8 @@
</template>
<script setup lang="ts">
import { type Location } from "~~/lib/api/classes/locations";
import { ItemCreate, LocationOut } from "~~/lib/api/types/data-contracts";
const props = defineProps({
modelValue: {
type: Boolean,
@ -40,7 +41,7 @@
const loading = ref(false);
const focused = ref(false);
const form = reactive({
location: {} as Location,
location: {} as LocationOut,
name: "",
description: "",
color: "", // Future!
@ -80,7 +81,7 @@
return;
}
const out = {
const out: ItemCreate = {
name: form.name,
description: form.description,
locationId: form.location.id as string,

View file

@ -1,10 +1,10 @@
<script setup lang="ts">
import { Label } from "~~/lib/api/classes/labels";
import { LabelOut, LabelSummary } from "~~/lib/api/types/data-contracts";
export type sizes = "sm" | "md" | "lg";
defineProps({
label: {
type: Object as () => Label,
type: Object as () => LabelOut | LabelSummary,
required: true,
},
size: {

View file

@ -26,11 +26,11 @@
</template>
<script lang="ts" setup>
import { Location } from "~~/lib/api/classes/locations";
import { LocationCount } from "~~/lib/api/types/data-contracts";
defineProps({
location: {
type: Object as () => Location,
type: Object as () => LocationCount,
required: true,
},
dense: {

View file

@ -0,0 +1,52 @@
<template>
{{ value }}
</template>
<script setup lang="ts">
enum DateTimeFormat {
RELATIVE = "relative",
LONG = "long",
SHORT = "short",
}
const value = computed(() => {
if (!props.date) {
return "";
}
const dt = typeof props.date === "string" ? new Date(props.date) : props.date;
if (!dt) {
return "";
}
if (nullDate(dt)) {
return "";
}
switch (props.format) {
case DateTimeFormat.RELATIVE:
return useTimeAgo(dt).value + useDateFormat(dt, " (MM-DD-YYYY)").value;
case DateTimeFormat.LONG:
return useDateFormat(dt, "YYYY-MM-DD (dddd)").value;
case DateTimeFormat.SHORT:
return useDateFormat(dt, "YYYY-MM-DD").value;
default:
return "";
}
});
function nullDate(dt: Date) {
return dt.getFullYear() === 1;
}
const props = defineProps({
date: {
type: [Date, String],
required: true,
},
format: {
type: String as () => DateTimeFormat,
default: "relative",
},
});
</script>

View file

@ -1,6 +1,7 @@
import { UseConfirmDialogReturn } from "@vueuse/core";
import { Ref } from "vue";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Store = UseConfirmDialogReturn<any, boolean, boolean> & {
text: Ref<string>;
setup: boolean;
@ -21,6 +22,7 @@ const store: Partial<Store> = {
export function useConfirm(): Store {
if (!store.setup) {
store.setup = true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { isRevealed, reveal, confirm, cancel } = useConfirmDialog<any, boolean, boolean>();
store.isRevealed = isRevealed;
store.reveal = reveal;

View file

@ -3,6 +3,7 @@ import { Ref } from "vue";
export type LocationViewPreferences = {
showDetails: boolean;
showEmpty: boolean;
editorSimpleView: boolean;
};
/**
@ -15,6 +16,7 @@ export function useViewPreferences(): Ref<LocationViewPreferences> {
{
showDetails: true,
showEmpty: true,
editorSimpleView: true,
},
{ mergeDefaults: true }
);

View file

@ -0,0 +1,35 @@
export function validDate(dt: Date | string | null | undefined): boolean {
if (!dt) {
return false;
}
// If it's a string, try to parse it
if (typeof dt === "string") {
const parsed = new Date(dt);
if (isNaN(parsed.getTime())) {
return false;
}
}
// If it's a date, check if it's valid
if (dt instanceof Date) {
if (dt.getFullYear() < 1000) {
return false;
}
}
return true;
}
export function fmtCurrency(value: number | string, currency = "USD", locale = "en-Us"): string {
if (typeof value === "string") {
value = parseFloat(value);
}
const formatter = new Intl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits: 2,
});
return formatter.format(value);
}

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { Label } from "../../classes/labels";
import { LabelOut } from "../../types/data-contracts";
import { UserApi } from "../../user";
import { sharedUserClient } from "../test-utils";
@ -10,7 +10,7 @@ describe("locations lifecycle (create, update, delete)", () => {
* useLabel sets up a label resource for testing, and returns a function
* that can be used to delete the label from the backend server.
*/
async function useLabel(api: UserApi): Promise<[Label, () => Promise<void>]> {
async function useLabel(api: UserApi): Promise<[LabelOut, () => Promise<void>]> {
const { response, data } = await api.labels.create({
name: `__test__.label.name_${increment}`,
description: `__test__.label.description_${increment}`,

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { Location } from "../../classes/locations";
import { LocationOut } from "../../types/data-contracts";
import { UserApi } from "../../user";
import { sharedUserClient } from "../test-utils";
@ -10,7 +10,7 @@ describe("locations lifecycle (create, update, delete)", () => {
* useLocatio sets up a location resource for testing, and returns a function
* that can be used to delete the location from the backend server.
*/
async function useLocation(api: UserApi): Promise<[Location, () => Promise<void>]> {
async function useLocation(api: UserApi): Promise<[LocationOut, () => Promise<void>]> {
const { response, data } = await api.locations.create({
name: `__test__.location.name_${increment}`,
description: `__test__.location.description_${increment}`,

View file

@ -0,0 +1,30 @@
import { describe, expect, test } from "vitest";
import { hasKey, parseDate } from "./base-api";
describe("hasKey works as expected", () => {
test("hasKey returns true if the key exists", () => {
const obj = { createdAt: "2021-01-01" };
expect(hasKey(obj, "createdAt")).toBe(true);
});
test("hasKey returns false if the key does not exist", () => {
const obj = { createdAt: "2021-01-01" };
expect(hasKey(obj, "updatedAt")).toBe(false);
});
});
describe("parseDate should work as expected", () => {
test("parseDate should set defaults", () => {
const obj = { createdAt: "2021-01-01", updatedAt: "2021-01-01" };
const result = parseDate(obj);
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.updatedAt).toBeInstanceOf(Date);
});
test("parseDate should set passed in types", () => {
const obj = { key1: "2021-01-01", key2: "2021-01-01" };
const result = parseDate(obj, ["key1", "key2"]);
expect(result.key1).toBeInstanceOf(Date);
expect(result.key2).toBeInstanceOf(Date);
});
});

View file

@ -8,10 +8,50 @@ import { Requests } from "../../requests";
// TDeleteResult = void
// >
type BaseApiType = {
createdAt: string;
updatedAt: string;
};
export function hasKey(obj: object, key: string): obj is Required<BaseApiType> {
return typeof obj[key] === "string";
}
export function parseDate<T>(obj: T, keys: Array<keyof T> = []): T {
const result = { ...obj };
[...keys, "createdAt", "updatedAt"].forEach(key => {
// @ts-ignore - we are checking for the key above
if (hasKey(result, key)) {
// @ts-ignore - we are guarding against this above
result[key] = new Date(result[key]);
}
});
return result;
}
export class BaseAPI {
http: Requests;
constructor(requests: Requests) {
this.http = requests;
}
/**
* dropFields will remove any fields that are specified in the fields array
* additionally, it will remove the `createdAt` and `updatedAt` fields if they
* are present. This is useful for when you want to send a subset of fields to
* the server like when performing an update.
*/
dropFields<T>(obj: T, keys: Array<keyof T> = []): T {
const result = { ...obj };
[...keys, "createdAt", "updatedAt"].forEach(key => {
// @ts-ignore - we are checking for the key above
if (hasKey(result, key)) {
// @ts-ignore - we are guarding against this above
delete result[key];
}
});
return result;
}
}

View file

@ -1,60 +1,26 @@
import { BaseAPI, route } from "../base";
import { Label } from "./labels";
import { Location } from "./locations";
import { parseDate } from "../base/base-api";
import { ItemCreate, ItemOut, ItemSummary, ItemUpdate } from "../types/data-contracts";
import { Results } from "./types";
export interface ItemCreate {
name: string;
description: string;
locationId: string;
labelIds: string[];
}
export interface Item {
createdAt: string;
description: string;
id: string;
labels: Label[];
location: Location;
manufacturer: string;
modelNumber: string;
name: string;
notes: string;
purchaseFrom: string;
purchasePrice: number;
purchaseTime: Date;
serialNumber: string;
soldNotes: string;
soldPrice: number;
soldTime: Date;
soldTo: string;
updatedAt: string;
lifetimeWarranty: boolean;
warrantyExpires: Date;
warrantyDetails: string;
}
export class ItemsApi extends BaseAPI {
getAll() {
return this.http.get<Results<Item>>({ url: route("/items") });
return this.http.get<Results<ItemOut>>({ url: route("/items") });
}
create(item: ItemCreate) {
return this.http.post<ItemCreate, Item>({ url: route("/items"), body: item });
return this.http.post<ItemCreate, ItemSummary>({ url: route("/items"), body: item });
}
async get(id: string) {
const payload = await this.http.get<Item>({ url: route(`/items/${id}`) });
const payload = await this.http.get<ItemOut>({ url: route(`/items/${id}`) });
if (!payload.data) {
return payload;
}
// Parse Date Types
payload.data.purchaseTime = new Date(payload.data.purchaseTime);
payload.data.soldTime = new Date(payload.data.soldTime);
payload.data.warrantyExpires = new Date(payload.data.warrantyExpires);
payload.data = parseDate(payload.data, ["purchaseTime", "soldTime", "warrantyExpires"]);
return payload;
}
@ -62,8 +28,17 @@ export class ItemsApi extends BaseAPI {
return this.http.delete<void>({ url: route(`/items/${id}`) });
}
update(id: string, item: ItemCreate) {
return this.http.put<ItemCreate, Item>({ url: route(`/items/${id}`), body: item });
async update(id: string, item: ItemUpdate) {
const payload = await this.http.put<ItemCreate, ItemOut>({
url: route(`/items/${id}`),
body: this.dropFields(item),
});
if (!payload.data) {
return payload;
}
payload.data = parseDate(payload.data, ["purchaseTime", "soldTime", "warrantyExpires"]);
return payload;
}
import(file: File) {

View file

@ -1,37 +1,25 @@
import { BaseAPI, route } from "../base";
import { Item } from "./items";
import { Details, OutType, Results } from "./types";
export type LabelCreate = Details & {
color: string;
};
export type LabelUpdate = LabelCreate;
export type Label = LabelCreate &
OutType & {
groupId: string;
items: Item[];
};
import { LabelCreate, LabelOut } from "../types/data-contracts";
import { Results } from "./types";
export class LabelsApi extends BaseAPI {
getAll() {
return this.http.get<Results<Label>>({ url: route("/labels") });
return this.http.get<Results<LabelOut>>({ url: route("/labels") });
}
create(body: LabelCreate) {
return this.http.post<LabelCreate, Label>({ url: route("/labels"), body });
return this.http.post<LabelCreate, LabelOut>({ url: route("/labels"), body });
}
get(id: string) {
return this.http.get<Label>({ url: route(`/labels/${id}`) });
return this.http.get<LabelOut>({ url: route(`/labels/${id}`) });
}
delete(id: string) {
return this.http.delete<void>({ url: route(`/labels/${id}`) });
}
update(id: string, body: LabelUpdate) {
return this.http.put<LabelUpdate, Label>({ url: route(`/labels/${id}`), body });
update(id: string, body: LabelCreate) {
return this.http.put<LabelCreate, LabelOut>({ url: route(`/labels/${id}`), body });
}
}

View file

@ -1,29 +1,20 @@
import { BaseAPI, route } from "../base";
import { Item } from "./items";
import { Details, OutType, Results } from "./types";
export type LocationCreate = Details;
export type Location = LocationCreate &
OutType & {
groupId: string;
items: Item[];
itemCount: number;
};
import { LocationCount, LocationCreate, LocationOut } from "../types/data-contracts";
import { Results } from "./types";
export type LocationUpdate = LocationCreate;
export class LocationsApi extends BaseAPI {
getAll() {
return this.http.get<Results<Location>>({ url: route("/locations") });
return this.http.get<Results<LocationCount>>({ url: route("/locations") });
}
create(body: LocationCreate) {
return this.http.post<LocationCreate, Location>({ url: route("/locations"), body });
return this.http.post<LocationCreate, LocationOut>({ url: route("/locations"), body });
}
get(id: string) {
return this.http.get<Location>({ url: route(`/locations/${id}`) });
return this.http.get<LocationOut>({ url: route(`/locations/${id}`) });
}
delete(id: string) {
@ -31,6 +22,6 @@ export class LocationsApi extends BaseAPI {
}
update(id: string, body: LocationUpdate) {
return this.http.put<LocationUpdate, Location>({ url: route(`/locations/${id}`), body });
return this.http.put<LocationUpdate, LocationOut>({ url: route(`/locations/${id}`), body });
}
}

View file

@ -1,19 +1,3 @@
/**
* OutType is the base type that is returned from the API.
* In contains the common fields that are included with every
* API response that isn't a bulk result
*/
export type OutType = {
id: string;
createdAt: string;
updatedAt: string;
};
export type Details = {
name: string;
description: string;
};
export type Results<T> = {
items: T[];
};

View file

@ -0,0 +1,262 @@
/* post-processed by ./scripts/process-types.py */
/* eslint-disable */
/* tslint:disable */
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
export interface ServerResult {
details: any;
error: boolean;
item: any;
message: string;
}
export interface ServerResults {
items: any;
}
export interface ApiSummary {
health: boolean;
message: string;
title: string;
versions: string[];
}
export interface DocumentOut {
id: string;
path: string;
title: string;
}
export interface ItemAttachment {
createdAt: Date;
document: DocumentOut;
id: string;
updatedAt: Date;
}
export interface ItemCreate {
description: string;
labelIds: string[];
/** Edges */
locationId: string;
name: string;
}
export interface ItemOut {
attachments: ItemAttachment[];
createdAt: Date;
description: string;
id: string;
insured: boolean;
labels: LabelSummary[];
/** Warranty */
lifetimeWarranty: boolean;
/** Edges */
location: LocationSummary;
manufacturer: string;
modelNumber: string;
name: string;
/** Extras */
notes: string;
purchaseFrom: string;
/** @example 0 */
purchasePrice: string;
/** Purchase */
purchaseTime: Date;
quantity: number;
/** Identifications */
serialNumber: string;
soldNotes: string;
/** @example 0 */
soldPrice: string;
/** Sold */
soldTime: Date;
soldTo: string;
updatedAt: Date;
warrantyDetails: string;
warrantyExpires: Date;
}
export interface ItemSummary {
createdAt: Date;
description: string;
id: string;
insured: boolean;
labels: LabelSummary[];
/** Warranty */
lifetimeWarranty: boolean;
/** Edges */
location: LocationSummary;
manufacturer: string;
modelNumber: string;
name: string;
/** Extras */
notes: string;
purchaseFrom: string;
/** @example 0 */
purchasePrice: string;
/** Purchase */
purchaseTime: Date;
quantity: number;
/** Identifications */
serialNumber: string;
soldNotes: string;
/** @example 0 */
soldPrice: string;
/** Sold */
soldTime: Date;
soldTo: string;
updatedAt: Date;
warrantyDetails: string;
warrantyExpires: Date;
}
export interface ItemUpdate {
description: string;
id: string;
insured: boolean;
labelIds: string[];
/** Warranty */
lifetimeWarranty: boolean;
/** Edges */
locationId: string;
manufacturer: string;
modelNumber: string;
name: string;
/** Extras */
notes: string;
purchaseFrom: string;
/** @example 0 */
purchasePrice: string;
/** Purchase */
purchaseTime: Date;
quantity: number;
/** Identifications */
serialNumber: string;
soldNotes: string;
/** @example 0 */
soldPrice: string;
/** Sold */
soldTime: Date;
soldTo: string;
warrantyDetails: string;
warrantyExpires: Date;
}
export interface LabelCreate {
color: string;
description: string;
name: string;
}
export interface LabelOut {
createdAt: Date;
description: string;
groupId: string;
id: string;
items: ItemSummary[];
name: string;
updatedAt: Date;
}
export interface LabelSummary {
createdAt: Date;
description: string;
groupId: string;
id: string;
name: string;
updatedAt: Date;
}
export interface LocationCount {
createdAt: Date;
description: string;
id: string;
itemCount: number;
name: string;
updatedAt: Date;
}
export interface LocationCreate {
description: string;
name: string;
}
export interface LocationOut {
createdAt: Date;
description: string;
id: string;
items: ItemSummary[];
name: string;
updatedAt: Date;
}
export interface LocationSummary {
createdAt: Date;
description: string;
id: string;
name: string;
updatedAt: Date;
}
export interface TokenResponse {
expiresAt: string;
token: string;
}
export interface UserIn {
email: string;
name: string;
password: string;
}
export interface UserOut {
email: string;
groupId: string;
groupName: string;
id: string;
isSuperuser: boolean;
name: string;
}
export interface UserRegistration {
groupName: string;
user: UserIn;
}
export interface UserUpdate {
email: string;
name: string;
}

View file

@ -55,7 +55,6 @@
function setFile(e: Event & { target: HTMLInputElement }) {
importCsv.value = e.target.files[0];
console.log("importCsv.value", importCsv.value);
}
const toast = useNotifier();

View file

@ -129,7 +129,7 @@
<div>
<h2 class="mt-1 text-4xl font-bold tracking-tight text-neutral-content sm:text-5xl lg:text-6xl flex">
HomeB
<AppLogo class="w-12 -mb-4" style="padding-left: 3px; padding-right: 2px" />
<AppLogo class="w-12 -mb-4" />
x
</h2>
<p class="ml-1 text-lg text-base-content/50">Track, Organize, and Manage your Shit.</p>

View file

@ -1,4 +1,6 @@
<script setup lang="ts">
import { ItemUpdate } from "~~/lib/api/types/data-contracts";
definePageMeta({
layout: "home",
});
@ -6,9 +8,20 @@
const route = useRoute();
const api = useUserApi();
const toast = useNotifier();
const preferences = useViewPreferences();
const itemId = computed<string>(() => route.params.id as string);
const { data: locations } = useAsyncData(async () => {
const { data } = await api.locations.getAll();
return data.items;
});
const { data: labels } = useAsyncData(async () => {
const { data } = await api.labels.getAll();
return data.items;
});
const { data: item } = useAsyncData(async () => {
const { data, error } = await api.items.get(itemId.value);
if (error) {
@ -16,11 +29,30 @@
navigateTo("/home");
return;
}
return data;
});
async function saveItem() {
const payload: ItemUpdate = {
...item.value,
locationId: item.value.location?.id,
labelIds: item.value.labels.map(l => l.id),
};
const { error } = await api.items.update(itemId.value, payload);
if (error) {
toast.error("Failed to save item");
return;
}
toast.success("Item saved");
navigateTo("/item/" + itemId.value);
}
type FormField = {
type: "text" | "textarea" | "select" | "date";
type: "text" | "textarea" | "select" | "date" | "label" | "location" | "number" | "checkbox";
label: string;
ref: string;
};
@ -31,6 +63,11 @@
label: "Name",
ref: "name",
},
{
type: "number",
label: "Quantity",
ref: "quantity",
},
{
type: "textarea",
label: "Description",
@ -56,6 +93,11 @@
label: "Notes",
ref: "notes",
},
{
type: "checkbox",
label: "Insured",
ref: "insured",
},
];
const purchaseFields: FormField[] = [
@ -76,6 +118,24 @@
},
];
const warrantyFields: FormField[] = [
{
type: "checkbox",
label: "Lifetime Warranty",
ref: "lifetimeWarranty",
},
{
type: "date",
label: "Warranty Expires",
ref: "warrantyExpires",
},
{
type: "textarea",
label: "Warranty Notes",
ref: "warrantyDetails",
},
];
const soldFields = [
{
type: "text",
@ -97,51 +157,193 @@
<template>
<BaseContainer v-if="item" class="pb-8">
<div class="space-y-4">
<div class="overflow-hidden card bg-base-100 shadow-xl sm:rounded-lg">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg font-medium leading-6">Item Details</h3>
</div>
<div class="border-t border-gray-300 sm:p-0">
<div v-for="field in mainFields" :key="field.ref" class="sm:divide-y sm:divide-gray-300 grid grid-cols-1">
<div class="pt-2 pb-4 sm:px-6 border-b border-gray-300">
<FormTextArea v-if="field.type === 'textarea'" v-model="item[field.ref]" :label="field.label" inline />
<FormTextField v-else-if="field.type === 'text'" v-model="item[field.ref]" :label="field.label" inline />
<FormDatePicker v-else-if="field.type === 'date'" v-model="item[field.ref]" :label="field.label" inline />
</div>
<section class="px-3">
<div class="space-y-4">
<div class="overflow-hidden card bg-base-100 shadow-xl sm:rounded-lg">
<BaseSectionHeader v-if="item" class="p-5">
<Icon name="mdi-package-variant" class="-mt-1 mr-2 text-gray-600" />
<span class="text-gray-600">
{{ item.name }}
</span>
<p class="text-sm text-gray-600 font-bold pb-0 mb-0">Quantity {{ item.quantity }}</p>
<template #after>
<div class="modal-action mt-3">
<div class="mr-auto tooltip" data-tip="Hide the cruft! ">
<label class="label cursor-pointer mr-auto">
<input v-model="preferences.editorSimpleView" type="checkbox" class="toggle toggle-primary" />
<span class="label-text ml-4"> Simple View </span>
</label>
</div>
<BaseButton size="sm" @click="saveItem">
<template #icon>
<Icon name="mdi-content-save-outline" />
</template>
Save
</BaseButton>
</div>
</template>
</BaseSectionHeader>
<div class="px-5 mb-6 grid md:grid-cols-2 gap-4">
<FormSelect v-model="item.location" label="Location" :items="locations ?? []" select-first />
<FormMultiselect v-model="item.labels" label="Labels" :items="labels ?? []" />
</div>
</div>
</div>
<div class="overflow-visible card bg-base-100 shadow-xl sm:rounded-lg">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg font-medium leading-6">Purchase Details</h3>
</div>
<div class="border-t border-gray-300 sm:p-0">
<div v-for="field in purchaseFields" :key="field.ref" class="sm:divide-y sm:divide-gray-300 grid grid-cols-1">
<div class="pt-2 pb-4 sm:px-6 border-b border-gray-300">
<FormTextArea v-if="field.type === 'textarea'" v-model="item[field.ref]" :label="field.label" inline />
<FormTextField v-else-if="field.type === 'text'" v-model="item[field.ref]" :label="field.label" inline />
<FormDatePicker v-else-if="field.type === 'date'" v-model="item[field.ref]" :label="field.label" inline />
<div class="border-t border-gray-300 sm:p-0">
<div v-for="field in mainFields" :key="field.ref" class="sm:divide-y sm:divide-gray-300 grid grid-cols-1">
<div class="pt-2 px-4 pb-4 sm:px-6 border-b border-gray-300">
<FormTextArea v-if="field.type === 'textarea'" v-model="item[field.ref]" :label="field.label" inline />
<FormTextField
v-else-if="field.type === 'text'"
v-model="item[field.ref]"
:label="field.label"
inline
/>
<FormTextField
v-else-if="field.type === 'number'"
v-model.number="item[field.ref]"
type="number"
:label="field.label"
inline
/>
<FormDatePicker
v-else-if="field.type === 'date'"
v-model="item[field.ref]"
:label="field.label"
inline
/>
<FormCheckbox
v-else-if="field.type === 'checkbox'"
v-model="item[field.ref]"
:label="field.label"
inline
/>
</div>
</div>
</div>
</div>
</div>
<div class="overflow-visible card bg-base-100 shadow-xl sm:rounded-lg">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg font-medium leading-6">Sold Details</h3>
<div v-if="!preferences.editorSimpleView" class="overflow-visible card bg-base-100 shadow-xl sm:rounded-lg">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg font-medium leading-6">Purchase Details</h3>
</div>
<div class="border-t border-gray-300 sm:p-0">
<div
v-for="field in purchaseFields"
:key="field.ref"
class="sm:divide-y sm:divide-gray-300 grid grid-cols-1"
>
<div class="pt-2 px-4 pb-4 sm:px-6 border-b border-gray-300">
<FormTextArea v-if="field.type === 'textarea'" v-model="item[field.ref]" :label="field.label" inline />
<FormTextField
v-else-if="field.type === 'text'"
v-model="item[field.ref]"
:label="field.label"
inline
/>
<FormTextField
v-else-if="field.type === 'number'"
v-model.number="item[field.ref]"
type="number"
:label="field.label"
inline
/>
<FormDatePicker
v-else-if="field.type === 'date'"
v-model="item[field.ref]"
:label="field.label"
inline
/>
<FormCheckbox
v-else-if="field.type === 'checkbox'"
v-model="item[field.ref]"
:label="field.label"
inline
/>
</div>
</div>
</div>
</div>
<div class="border-t border-gray-300 sm:p-0">
<div v-for="field in soldFields" :key="field.ref" class="sm:divide-y sm:divide-gray-300 grid grid-cols-1">
<div class="pt-2 pb-4 sm:px-6 border-b border-gray-300">
<FormTextArea v-if="field.type === 'textarea'" v-model="item[field.ref]" :label="field.label" inline />
<FormTextField v-else-if="field.type === 'text'" v-model="item[field.ref]" :label="field.label" inline />
<FormDatePicker v-else-if="field.type === 'date'" v-model="item[field.ref]" :label="field.label" inline />
<div v-if="!preferences.editorSimpleView" class="overflow-visible card bg-base-100 shadow-xl sm:rounded-lg">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg font-medium leading-6">Warranty Details</h3>
</div>
<div class="border-t border-gray-300 sm:p-0">
<div
v-for="field in warrantyFields"
:key="field.ref"
class="sm:divide-y sm:divide-gray-300 grid grid-cols-1"
>
<div class="pt-2 px-4 pb-4 sm:px-6 border-b border-gray-300">
<FormTextArea v-if="field.type === 'textarea'" v-model="item[field.ref]" :label="field.label" inline />
<FormTextField
v-else-if="field.type === 'text'"
v-model="item[field.ref]"
:label="field.label"
inline
/>
<FormTextField
v-else-if="field.type === 'number'"
v-model.number="item[field.ref]"
type="number"
:label="field.label"
inline
/>
<FormDatePicker
v-else-if="field.type === 'date'"
v-model="item[field.ref]"
:label="field.label"
inline
/>
<FormCheckbox
v-else-if="field.type === 'checkbox'"
v-model="item[field.ref]"
:label="field.label"
inline
/>
</div>
</div>
</div>
</div>
<div v-if="!preferences.editorSimpleView" class="overflow-visible card bg-base-100 shadow-xl sm:rounded-lg">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg font-medium leading-6">Sold Details</h3>
</div>
<div class="border-t border-gray-300 sm:p-0">
<div v-for="field in soldFields" :key="field.ref" class="sm:divide-y sm:divide-gray-300 grid grid-cols-1">
<div class="pt-2 pb-4 px-4 sm:px-6 border-b border-gray-300">
<FormTextArea v-if="field.type === 'textarea'" v-model="item[field.ref]" :label="field.label" inline />
<FormTextField
v-else-if="field.type === 'text'"
v-model="item[field.ref]"
:label="field.label"
inline
/>
<FormTextField
v-else-if="field.type === 'number'"
v-model.number="item[field.ref]"
type="number"
:label="field.label"
inline
/>
<FormDatePicker
v-else-if="field.type === 'date'"
v-model="item[field.ref]"
:label="field.label"
inline
/>
<FormCheckbox
v-else-if="field.type === 'checkbox'"
v-model="item[field.ref]"
:label="field.label"
inline
/>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</BaseContainer>
</template>

View file

@ -10,7 +10,7 @@
const itemId = computed<string>(() => route.params.id as string);
const preferences = useViewPreferences();
const { data: item } = useAsyncData(async () => {
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");
@ -20,6 +20,11 @@
return data;
});
// Trigger Refresh on navigate
onMounted(() => {
refresh();
});
const itemSummary = computed(() => {
return {
Description: item.value?.description || "",
@ -27,6 +32,7 @@
"Model Number": item.value?.modelNumber || "",
Manufacturer: item.value?.manufacturer || "",
Notes: item.value?.notes || "",
Insured: item.value?.insured ? "Yes" : "No",
Attachments: "", // TODO: Attachments
};
});
@ -35,15 +41,15 @@
if (preferences.value.showEmpty) {
return true;
}
return item.value?.warrantyExpires !== undefined;
return validDate(item.value?.warrantyExpires);
});
const warrantyDetails = computed(() => {
const payload = {};
const payload = {
"Lifetime Warranty": item.value?.lifetimeWarranty ? "Yes" : "No",
};
if (item.value.lifetimeWarranty) {
payload["Lifetime Warranty"] = "Yes";
} else {
if (showWarranty.value) {
payload["Warranty Expires"] = item.value?.warrantyExpires || "";
}
@ -62,7 +68,7 @@
const purchaseDetails = computed(() => {
return {
"Purchased From": item.value?.purchaseFrom || "",
"Purchased Price": item.value?.purchasePrice || "",
"Purchased Price": item.value?.purchasePrice ? fmtCurrency(item.value.purchasePrice) : "",
"Purchased At": item.value?.purchaseTime || "",
};
});
@ -78,7 +84,7 @@
const soldDetails = computed(() => {
return {
"Sold To": item.value?.soldTo || "",
"Sold Price": item.value?.soldPrice || "",
"Sold Price": item.value?.soldPrice ? fmtCurrency(item.value.soldPrice) : "",
"Sold At": item.value?.soldTime || "",
};
});
@ -103,7 +109,7 @@
</script>
<template>
<BaseContainer class="pb-8">
<BaseContainer v-if="item" class="pb-8">
<section class="px-3">
<div class="flex justify-between items-center">
<div class="form-control"></div>
@ -116,11 +122,14 @@
<span class="text-gray-600">
{{ item.name }}
</span>
<p class="text-sm text-gray-600 font-bold pb-0 mb-0">
{{ item.location.name }} - Quantity {{ item.quantity }}
</p>
<template #after>
<div class="flex flex-wrap gap-3 mt-3">
<div v-if="item.labels && item.labels.length > 0" class="flex flex-wrap gap-3 mt-3">
<LabelChip v-for="label in item.labels" :key="label.id" class="badge-primary" :label="label" />
</div>
<div class="modal-action">
<div class="modal-action mt-3">
<label class="label cursor-pointer mr-auto">
<input v-model="preferences.showEmpty" type="checkbox" class="toggle toggle-primary" />
<span class="label-text ml-4"> Show Empty </span>
@ -166,12 +175,21 @@
</BaseDetails>
<BaseDetails v-if="showPurchase" :details="purchaseDetails">
<template #title> Purchase Details </template>
<template #PurchasedAt>
<DateTime :date="item.purchaseTime" />
</template>
</BaseDetails>
<BaseDetails v-if="showWarranty" :details="warrantyDetails">
<template #title> Warranty </template>
<template #WarrantyExpires>
<DateTime :date="item.warrantyExpires" />
</template>
</BaseDetails>
<BaseDetails v-if="showSold" :details="soldDetails">
<template #title> Sold </template>
<template #SoldAt>
<DateTime :date="item.soldTime" />
</template>
</BaseDetails>
</div>
</section>

View file

@ -1 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M2,10.96C1.5,10.68 1.35,10.07 1.63,9.59L3.13,7C3.24,6.8 3.41,6.66 3.6,6.58L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.66,6.72 20.82,6.88 20.91,7.08L22.36,9.6C22.64,10.08 22.47,10.69 22,10.96L21,11.54V16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V10.96C2.7,11.13 2.32,11.14 2,10.96M12,4.15V4.15L12,10.85V10.85L17.96,7.5L12,4.15M5,15.91L11,19.29V12.58L5,9.21V15.91M19,15.91V12.69L14,15.59C13.67,15.77 13.3,15.76 13,15.6V19.29L19,15.91M13.85,13.36L20.13,9.73L19.55,8.72L13.27,12.35L13.85,13.36Z" /></svg>
<svg viewBox="0 0 10817 9730" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:5.42683">
<path d="M9310.16 2560.9c245.302 249.894 419.711 539.916 565.373 845.231 47.039 98.872 36.229 215.514-28.2 304.05-64.391 88.536-172.099 134.676-280.631 120.28 0 .053-.039.053-.039.053" style="fill:gray;stroke:#000;stroke-width:206.41px"/>
<path d="M5401.56 487.044c-127.958 6.227-254.855 40.77-370.992 103.628-765.271 414.225-2397.45 1297.68-3193.03 1728.32-137.966 74.669-250.327 183.605-328.791 313.046l3963.09 2122.43s-249.048 416.428-470.593 786.926c-189.24 316.445-592.833 429.831-919.198 258.219l-2699.36-1419.32v2215.59c0 226.273 128.751 435.33 337.755 548.466 764.649 413.885 2620.97 1418.66 3385.59 1832.51 209.018 113.137 466.496 113.137 675.514 0 764.623-413.857 2620.94-1418.63 3385.59-1832.51 208.989-113.136 337.743-322.193 337.743-548.466v-3513.48c0-318.684-174.59-611.722-454.853-763.409-795.543-430.632-2427.75-1314.09-3193.02-1728.32-141.693-76.684-299.364-111.227-455.442-103.628" style="fill:#dadada;stroke:#000;stroke-width:206.42px"/>
<path d="M5471.83 4754.46V504.71c-127.958 6.226-325.127 23.1-441.264 85.958-765.271 414.225-2397.45 1297.68-3193.03 1728.32-137.966 74.669-250.327 183.605-328.791 313.046l3963.09 2122.43Z" style="fill:gray;stroke:#000;stroke-width:206.42px"/>
<path d="m1459.34 2725.96-373.791 715.667c-177.166 339.292-46.417 758 292.375 936.167l4.75 2.5m0 0 2699.37 1419.29c326.374 171.625 729.916 58.25 919.165-258.208 221.542-370.5 470.583-786.917 470.583-786.917l-3963.04-2122.42-2.167 3.458-47.25 90.458" style="fill:#dadada;stroke:#000;stroke-width:206.42px"/>
<path d="M5443.74 520.879v4149.79" style="fill:none;stroke:#000;stroke-width:153.5px"/>
<path d="M8951.41 4102.72c0-41.65-22.221-80.136-58.291-100.961-36.069-20.825-80.51-20.825-116.58 0l-2439.92 1408.69c-36.07 20.825-58.29 59.311-58.29 100.961V7058c0 41.65 22.22 80.136 58.29 100.961 36.07 20.825 80.51 20.825 116.58 0l2439.92-1408.69c36.07-20.825 58.291-59.312 58.291-100.962v-1546.59Z" style="fill:#567f67"/>
<path d="M8951.41 4102.72c0-41.65-22.221-80.136-58.291-100.961-36.069-20.825-80.51-20.825-116.58 0l-2439.92 1408.69c-36.07 20.825-58.29 59.311-58.29 100.961V7058c0 41.65 22.22 80.136 58.29 100.961 36.07 20.825 80.51 20.825 116.58 0l2439.92-1408.69c36.07-20.825 58.291-59.312 58.291-100.962v-1546.59ZM6463.98 5551.29v1387.06l2301.77-1328.92V4222.37L6463.98 5551.29Z"/>
<path d="M5443.76 9041.74v-4278.4" style="fill:none;stroke:#000;stroke-width:206.44px;stroke-linejoin:miter"/>
<path d="m5471.79 4773.86 3829.35-2188.22" style="fill:none;stroke:#000;stroke-width:206.43px;stroke-linejoin:miter"/>
</svg>

Before

Width:  |  Height:  |  Size: 707 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Before After
Before After