wip: maintenance log

This commit is contained in:
Hayden 2022-12-09 20:52:04 -09:00
parent 395affc7a6
commit 20018ebc00
No known key found for this signature in database
GPG key ID: 17CF79474E257545
12 changed files with 938 additions and 717 deletions

View file

@ -91,6 +91,7 @@ func (r *MaintenanceEntryRepository) GetLog(ctx context.Context, itemID uuid.UUI
entries, err := r.db.MaintenanceEntry.Query(). entries, err := r.db.MaintenanceEntry.Query().
Where(maintenanceentry.ItemID(itemID)). Where(maintenanceentry.ItemID(itemID)).
Order(ent.Desc(maintenanceentry.FieldDate)).
All(ctx) All(ctx)
if err != nil { if err != nil {

View file

@ -9,6 +9,7 @@
'btn-sm': size === 'sm', 'btn-sm': size === 'sm',
'btn-lg': size === 'lg', 'btn-lg': size === 'lg',
}" }"
:style="upper ? '' : 'text-transform: none'"
> >
<label v-if="$slots.icon" class="swap swap-rotate mr-2" :class="{ 'swap-active': isHover }"> <label v-if="$slots.icon" class="swap swap-rotate mr-2" :class="{ 'swap-active': isHover }">
<slot name="icon" /> <slot name="icon" />

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="card bg-base-100 shadow-xl sm:rounded-lg"> <div class="card bg-base-100 shadow-xl sm:rounded-lg">
<div class="px-4 py-5 sm:px-6"> <div v-if="$slots.title" class="px-4 py-5 sm:px-6">
<h3 class="text-lg font-medium leading-6"> <h3 class="text-lg font-medium leading-6">
<slot name="title"></slot> <slot name="title"></slot>
</h3> </h3>

View file

@ -13,6 +13,7 @@
<button <button
v-if="day.number != ''" v-if="day.number != ''"
:key="day.number" :key="day.number"
type="button"
class="text-center btn-xs btn btn-outline" class="text-center btn-xs btn btn-outline"
@click="select($event, day.date)" @click="select($event, day.date)"
> >
@ -22,11 +23,11 @@
</template> </template>
</div> </div>
<div class="flex justify-between mt-1 items-center"> <div class="flex justify-between mt-1 items-center">
<button class="btn btn-xs" @click="prevMonth"> <button type="button" class="btn btn-xs" @click="prevMonth">
<Icon class="h-5 w-5" name="mdi-arrow-left"></Icon> <Icon class="h-5 w-5" name="mdi-arrow-left"></Icon>
</button> </button>
<p class="text-center">{{ month }} {{ year }}</p> <p class="text-center">{{ month }} {{ year }}</p>
<button class="btn btn-xs" @click="nextMonth"> <button type="button" class="btn btn-xs" @click="nextMonth">
<Icon class="h-5 w-5" name="mdi-arrow-right"></Icon> <Icon class="h-5 w-5" name="mdi-arrow-right"></Icon>
</button> </button>
</div> </div>

View file

@ -3,11 +3,36 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
enum DateTimeFormat { type DateTimeFormat = "relative" | "long" | "short" | "human";
RELATIVE = "relative",
LONG = "long", function ordinalIndicator(num: number) {
SHORT = "short", if (num > 3 && num < 21) return "th";
switch (num % 10) {
case 1:
return "st";
case 2:
return "nd";
case 3:
return "rd";
default:
return "th";
} }
}
const months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const value = computed(() => { const value = computed(() => {
if (!props.date) { if (!props.date) {
@ -24,12 +49,15 @@
} }
switch (props.format) { switch (props.format) {
case DateTimeFormat.RELATIVE: case "relative":
return useTimeAgo(dt).value + useDateFormat(dt, " (MM-DD-YYYY)").value; return useTimeAgo(dt).value + useDateFormat(dt, " (MM-DD-YYYY)").value;
case DateTimeFormat.LONG: case "long":
return useDateFormat(dt, "MM-DD-YYYY (dddd)").value; return useDateFormat(dt, "MM-DD-YYYY (dddd)").value;
case DateTimeFormat.SHORT: case "short":
return useDateFormat(dt, "MM-DD-YYYY").value; return useDateFormat(dt, "MM-DD-YYYY").value;
case "human":
// January 1st, 2021
return `${months[dt.getMonth()]} ${dt.getDate()}${ordinalIndicator(dt.getDate())}, ${dt.getFullYear()}`;
default: default:
return ""; return "";
} }

View file

@ -3,10 +3,12 @@
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
type Props = { type Props = {
source: string; source: string | null | undefined;
}; };
const props = withDefaults(defineProps<Props>(), {}); const props = withDefaults(defineProps<Props>(), {
source: null,
});
const md = new MarkdownIt({ const md = new MarkdownIt({
html: true, html: true,
@ -15,7 +17,7 @@
}); });
const raw = computed(() => { const raw = computed(() => {
const html = md.render(props.source); const html = md.render(props.source || "");
return DOMPurify.sanitize(html); return DOMPurify.sanitize(html);
}); });
</script> </script>

View file

@ -1,20 +1,16 @@
import { defineNuxtConfig } from "nuxt"; import { defineNuxtConfig } from "nuxt/config";
// https://v3.nuxtjs.org/api/configuration/nuxt.config // https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({ export default defineNuxtConfig({
target: "static",
ssr: false, ssr: false,
modules: ["@nuxtjs/tailwindcss", "@pinia/nuxt", "@vueuse/nuxt"], modules: ["@nuxtjs/tailwindcss", "@pinia/nuxt", "@vueuse/nuxt"],
meta: { nitro: {
title: "Homebox", devProxy: {
link: [{ rel: "icon", type: "image/x-icon", href: "/favicon.svg" }], "/api": {
}, target: "http://localhost:7745/api",
vite: { changeOrigin: true,
server: { }
proxy: {
"/api": "http://localhost:7745",
}, },
}, },
plugins: [], plugins: [],
},
}); });

View file

@ -21,7 +21,7 @@
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-vue": "^9.4.0", "eslint-plugin-vue": "^9.4.0",
"isomorphic-fetch": "^3.0.0", "isomorphic-fetch": "^3.0.0",
"nuxt": "3.0.0-rc.11", "nuxt": "3.0.0",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"typescript": "^4.8.3", "typescript": "^4.8.3",
"vite-plugin-eslint": "^1.8.1", "vite-plugin-eslint": "^1.8.1",
@ -29,7 +29,7 @@
}, },
"dependencies": { "dependencies": {
"@iconify/vue": "^3.2.1", "@iconify/vue": "^3.2.1",
"@nuxtjs/tailwindcss": "^5.3.2", "@nuxtjs/tailwindcss": "^6.1.3",
"@pinia/nuxt": "^0.4.1", "@pinia/nuxt": "^0.4.1",
"@tailwindcss/aspect-ratio": "^0.4.0", "@tailwindcss/aspect-ratio": "^0.4.0",
"@tailwindcss/forms": "^0.5.2", "@tailwindcss/forms": "^0.5.2",

View file

@ -46,7 +46,7 @@
const importDialog = ref(false); const importDialog = ref(false);
const importCsv = ref(null); const importCsv = ref(null);
const importLoading = ref(false); const importLoading = ref(false);
const importRef = ref<HTMLInputElement>(null); const importRef = ref<HTMLInputElement>();
whenever( whenever(
() => !importDialog.value, () => !importDialog.value,
() => { () => {
@ -120,7 +120,7 @@
<section> <section>
<BaseCard> <BaseCard>
<template #title> Welcome Back, {{ auth.self ? auth.self.name : "Username" }} </template> <template #title> Welcome Back, {{ auth.self ? auth.self.name : "Username" }} </template>
<template #subtitle> {{ auth.self.isSuperuser ? "Admin" : "User" }} </template> <!-- <template #subtitle> {{ auth.self.isSuperuser ? "Admin" : "User" }} </template> -->
<template #title-actions> <template #title-actions>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<div class="tooltip" data-tip="Import CSV File"> <div class="tooltip" data-tip="Import CSV File">

View file

@ -13,6 +13,10 @@
const itemId = computed<string>(() => route.params.id as string); const itemId = computed<string>(() => route.params.id as string);
const preferences = useViewPreferences(); 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: item, refresh } = useAsyncData(itemId.value, async () => {
const { data, error } = await api.items.get(itemId.value); const { data, error } = await api.items.get(itemId.value);
if (error) { if (error) {
@ -219,7 +223,7 @@
} else { } else {
details.push({ details.push({
name: "Warranty Expires", name: "Warranty Expires",
text: item.value?.warrantyExpires, text: item.value?.warrantyExpires || "",
type: "date", type: "date",
}); });
} }
@ -253,7 +257,7 @@
}, },
{ {
name: "Purchase Date", name: "Purchase Date",
text: item.value.purchaseTime, text: item.value?.purchaseTime || "",
type: "date", type: "date",
}, },
]; ];
@ -309,12 +313,12 @@
}); });
function openDialog(img: Photo) { function openDialog(img: Photo) {
refDialog.value.showModal(); refDialog.value?.showModal();
dialoged.src = img.src; dialoged.src = img.src;
} }
function closeDialog() { function closeDialog() {
refDialog.value.close(); refDialog.value?.close();
} }
const refDialogBody = ref<HTMLDivElement>(); const refDialogBody = ref<HTMLDivElement>();
@ -340,10 +344,7 @@
</div> </div>
</dialog> </dialog>
<section class="px-3"> <section class="px-3">
<div class="flex justify-between items-center"> <div class="space-y-3">
<div class="form-control"></div>
</div>
<div class="grid grid-cols-1 gap-3">
<BaseCard> <BaseCard>
<template #title> <template #title>
<BaseSectionHeader> <BaseSectionHeader>
@ -374,10 +375,16 @@
</template> </template>
<template #title-actions> <template #title-actions>
<div class="modal-action mt-0"> <div class="modal-action mt-0">
<label class="label cursor-pointer mr-auto"> <label v-if="!hasNested" class="label cursor-pointer mr-auto">
<input v-model="preferences.showEmpty" type="checkbox" class="toggle toggle-primary" /> <input v-model="preferences.showEmpty" type="checkbox" class="toggle toggle-primary" />
<span class="label-text ml-4"> Show Empty </span> <span class="label-text ml-4"> Show Empty </span>
</label> </label>
<BaseButton v-else class="mr-auto" size="sm" @click="$router.go(-1)">
<template #icon>
<Icon name="mdi-arrow-left" class="h-5 w-5" />
</template>
Back
</BaseButton>
<BaseButton size="sm" :to="`/item/${itemId}/edit`"> <BaseButton size="sm" :to="`/item/${itemId}/edit`">
<template #icon> <template #icon>
<Icon name="mdi-pencil" /> <Icon name="mdi-pencil" />
@ -390,12 +397,20 @@
</template> </template>
Delete Delete
</BaseButton> </BaseButton>
<BaseButton size="sm" :to="`/item/${itemId}/log`">
<template #icon>
<Icon name="mdi-post" />
</template>
Log
</BaseButton>
</div> </div>
</template> </template>
<DetailsSection :details="itemDetails" /> <DetailsSection v-if="!hasNested" :details="itemDetails" />
</BaseCard> </BaseCard>
<NuxtPage :item="item" :page-key="itemId" />
<div v-if="!hasNested">
<BaseCard v-if="photos && photos.length > 0"> <BaseCard v-if="photos && photos.length > 0">
<template #title> Photos </template> <template #title> Photos </template>
<div <div
@ -456,9 +471,10 @@
<DetailsSection :details="soldDetails" /> <DetailsSection :details="soldDetails" />
</BaseCard> </BaseCard>
</div> </div>
</div>
</section> </section>
<section class="my-6 px-3"> <section v-if="!hasNested" class="my-6 px-3">
<BaseSectionHeader v-if="item && item.children && item.children.length > 0"> Child Items </BaseSectionHeader> <BaseSectionHeader v-if="item && item.children && item.children.length > 0"> Child Items </BaseSectionHeader>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<ItemCard v-for="child in item.children" :key="child.id" :item="child" /> <ItemCard v-for="child in item.children" :key="child.id" :item="child" />

View file

@ -0,0 +1,173 @@
<script setup lang="ts">
import DatePicker from "~~/components/Form/DatePicker.vue";
import { ItemOut } from "~~/lib/api/types/data-contracts";
const props = defineProps<{
item: ItemOut;
}>();
const api = useUserApi();
const toast = useNotifier();
const { data: log, refresh: refreshLog } = useAsyncData(async () => {
const { data } = await api.items.maintenance.getLog(props.item.id);
return data;
});
const stats = computed(() => {
if (!log.value) return [];
return [
{
id: "total",
title: "Total Cost",
subtitle: "Sum over all entries",
value: fmtCurrency(log.value.costTotal),
},
{
id: "average",
title: "Monthly Average",
subtitle: "Average over all entries",
value: fmtCurrency(log.value.costAverage),
},
];
});
const entry = reactive({
modal: false,
name: "",
date: new Date(),
description: "",
cost: "",
});
function newEntry() {
entry.modal = true;
}
async function createEntry() {
const { error } = await api.items.maintenance.create(props.item.id, {
name: entry.name,
date: entry.date,
description: entry.description,
cost: entry.cost,
});
if (error) {
toast.error("Failed to create entry");
return;
}
entry.modal = false;
refreshLog();
}
async function deleteEntry(id: string) {
const { error } = await api.items.maintenance.delete(props.item.id, id);
if (error) {
toast.error("Failed to delete entry");
return;
}
refreshLog();
}
</script>
<template>
<div v-if="log">
<BaseModal v-model="entry.modal">
<template #title> Create Entry </template>
<form @submit.prevent="createEntry">
<FormTextField v-model="entry.name" autofocus label="Entry Name" />
<DatePicker v-model="entry.date" label="Date" />
<FormTextArea v-model="entry.description" label="Notes" />
<FormTextField v-model="entry.cost" autofocus label="Cost" />
<div class="py-2 flex justify-end">
<BaseButton type="submit" class="ml-2">
<template #icon>
<Icon name="mdi-post" />
</template>
Create
</BaseButton>
</div>
</form>
</BaseModal>
<div class="flex">
<BaseButton class="ml-auto" size="sm" @click="newEntry()">
<template #icon>
<Icon name="mdi-post" />
</template>
Log Maintenance
</BaseButton>
</div>
<section class="page-layout my-6">
<div class="main-slot container space-y-6">
<BaseCard v-for="e in log.entries" :key="e.id">
<BaseSectionHeader class="p-6 border-b border-b-gray-300">
<span class="text-base-content">
{{ e.name }}
</span>
<template #description>
<div class="flex gap-2">
<div class="badge p-3">
<Icon name="mdi-calendar" class="mr-2" />
<DateTime :date="e.date" format="human" />
</div>
<div class="tooltip tooltip-primary" data-tip="Cost">
<div class="badge badge-primary p-3">
<Currency :amount="e.cost" />
</div>
</div>
</div>
</template>
</BaseSectionHeader>
<div class="p-6">
<Markdown :source="e.description" />
</div>
<div class="flex justify-end p-4">
<BaseButton size="sm" @click="deleteEntry(e.id)">
<template #icon>
<Icon name="mdi-delete" />
</template>
Delete
</BaseButton>
</div>
</BaseCard>
</div>
<div class="side-slot space-y-6">
<div v-for="stat in stats" :key="stat.id" class="stats block shadow-xl border-l-primary">
<div class="stat">
<div class="stat-title">{{ stat.title }}</div>
<div class="stat-value text-primary">{{ stat.value }}</div>
<div class="stat-desc">{{ stat.subtitle }}</div>
</div>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.page-layout {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: auto;
grid-template-areas: "side main";
gap: 1rem;
}
.side-slot {
grid-area: side;
}
.main-slot {
grid-area: main;
}
.grid {
display: grid;
}
</style>

1247
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff