forked from mirrors/homebox
feat: maintenance log (#170)
* remove repo for document tokens * remove schema for doc tokens * fix id template and generate cmd * schema updates * code gen * bump dependencies * fix broken migrations + add maintenance entry type * spelling * remove debug logger * implement repository layer * routes * API client * wip: maintenance log * remove depreciated call
This commit is contained in:
parent
d6da63187b
commit
5bbb969763
79 changed files with 6320 additions and 4957 deletions
|
@ -9,6 +9,7 @@
|
|||
'btn-sm': size === 'sm',
|
||||
'btn-lg': size === 'lg',
|
||||
}"
|
||||
:style="upper ? '' : 'text-transform: none'"
|
||||
>
|
||||
<label v-if="$slots.icon" class="swap swap-rotate mr-2" :class="{ 'swap-active': isHover }">
|
||||
<slot name="icon" />
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<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">
|
||||
<slot name="title"></slot>
|
||||
</h3>
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
<button
|
||||
v-if="day.number != ''"
|
||||
:key="day.number"
|
||||
type="button"
|
||||
class="text-center btn-xs btn btn-outline"
|
||||
@click="select($event, day.date)"
|
||||
>
|
||||
|
@ -22,11 +23,11 @@
|
|||
</template>
|
||||
</div>
|
||||
<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>
|
||||
</button>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -3,12 +3,37 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
enum DateTimeFormat {
|
||||
RELATIVE = "relative",
|
||||
LONG = "long",
|
||||
SHORT = "short",
|
||||
type DateTimeFormat = "relative" | "long" | "short" | "human";
|
||||
|
||||
function ordinalIndicator(num: number) {
|
||||
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(() => {
|
||||
if (!props.date) {
|
||||
return "";
|
||||
|
@ -24,12 +49,15 @@
|
|||
}
|
||||
|
||||
switch (props.format) {
|
||||
case DateTimeFormat.RELATIVE:
|
||||
case "relative":
|
||||
return useTimeAgo(dt).value + useDateFormat(dt, " (MM-DD-YYYY)").value;
|
||||
case DateTimeFormat.LONG:
|
||||
case "long":
|
||||
return useDateFormat(dt, "MM-DD-YYYY (dddd)").value;
|
||||
case DateTimeFormat.SHORT:
|
||||
case "short":
|
||||
return useDateFormat(dt, "MM-DD-YYYY").value;
|
||||
case "human":
|
||||
// January 1st, 2021
|
||||
return `${months[dt.getMonth()]} ${dt.getDate()}${ordinalIndicator(dt.getDate())}, ${dt.getFullYear()}`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
|
|
@ -3,10 +3,12 @@
|
|||
import DOMPurify from "dompurify";
|
||||
|
||||
type Props = {
|
||||
source: string;
|
||||
source: string | null | undefined;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
source: null,
|
||||
});
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
|
@ -15,7 +17,7 @@
|
|||
});
|
||||
|
||||
const raw = computed(() => {
|
||||
const html = md.render(props.source);
|
||||
const html = md.render(props.source || "");
|
||||
return DOMPurify.sanitize(html);
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -33,6 +33,7 @@ describe("user should be able to create an item and add an attachment", () => {
|
|||
const [location, cleanup] = await useLocation(api);
|
||||
|
||||
const { response, data: item } = await api.items.create({
|
||||
parentId: null,
|
||||
name: "test-item",
|
||||
labelIds: [],
|
||||
description: "test-description",
|
||||
|
@ -43,7 +44,7 @@ describe("user should be able to create an item and add an attachment", () => {
|
|||
// Add attachment
|
||||
{
|
||||
const testFile = new Blob(["test"], { type: "text/plain" });
|
||||
const { response } = await api.items.addAttachment(item.id, testFile, "test.txt", AttachmentTypes.Attachment);
|
||||
const { response } = await api.items.attachments.add(item.id, testFile, "test.txt", AttachmentTypes.Attachment);
|
||||
expect(response.status).toBe(201);
|
||||
}
|
||||
|
||||
|
@ -54,7 +55,7 @@ describe("user should be able to create an item and add an attachment", () => {
|
|||
expect(data.attachments).toHaveLength(1);
|
||||
expect(data.attachments[0].document.title).toBe("test.txt");
|
||||
|
||||
const resp = await api.items.deleteAttachment(data.id, data.attachments[0].id);
|
||||
const resp = await api.items.attachments.delete(data.id, data.attachments[0].id);
|
||||
expect(resp.response.status).toBe(204);
|
||||
|
||||
api.items.delete(item.id);
|
||||
|
@ -66,6 +67,7 @@ describe("user should be able to create an item and add an attachment", () => {
|
|||
const [location, cleanup] = await useLocation(api);
|
||||
|
||||
const { response, data: item } = await api.items.create({
|
||||
parentId: null,
|
||||
name: faker.vehicle.model(),
|
||||
labelIds: [],
|
||||
description: faker.lorem.paragraph(1),
|
||||
|
@ -82,6 +84,7 @@ describe("user should be able to create an item and add an attachment", () => {
|
|||
|
||||
// Add fields
|
||||
const itemUpdate = {
|
||||
parentId: null,
|
||||
...item,
|
||||
locationId: item.location.id,
|
||||
labelIds: item.labels.map(l => l.id),
|
||||
|
@ -113,4 +116,41 @@ describe("user should be able to create an item and add an attachment", () => {
|
|||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("users should be able to create and few maintenance logs for an item", async () => {
|
||||
const api = await sharedUserClient();
|
||||
const [location, cleanup] = await useLocation(api);
|
||||
const { response, data: item } = await api.items.create({
|
||||
parentId: null,
|
||||
name: faker.vehicle.model(),
|
||||
labelIds: [],
|
||||
description: faker.lorem.paragraph(1),
|
||||
locationId: location.id,
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
const maintenanceEntries = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const { response, data } = await api.items.maintenance.create(item.id, {
|
||||
name: faker.vehicle.model(),
|
||||
description: faker.lorem.paragraph(1),
|
||||
date: faker.date.past(1),
|
||||
cost: faker.datatype.number(100).toString(),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
maintenanceEntries.push(data);
|
||||
}
|
||||
|
||||
// Log
|
||||
{
|
||||
const { response, data } = await api.items.maintenance.getLog(item.id);
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.entries).toHaveLength(maintenanceEntries.length);
|
||||
expect(data.costAverage).toBeGreaterThan(0);
|
||||
expect(data.costTotal).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,7 +1,18 @@
|
|||
import { BaseAPI, route } from "../base";
|
||||
import { parseDate } from "../base/base-api";
|
||||
import { ItemAttachmentUpdate, ItemCreate, ItemOut, ItemSummary, ItemUpdate } from "../types/data-contracts";
|
||||
import {
|
||||
ItemAttachmentUpdate,
|
||||
ItemCreate,
|
||||
ItemOut,
|
||||
ItemSummary,
|
||||
ItemUpdate,
|
||||
MaintenanceEntry,
|
||||
MaintenanceEntryCreate,
|
||||
MaintenanceEntryUpdate,
|
||||
MaintenanceLog,
|
||||
} from "../types/data-contracts";
|
||||
import { AttachmentTypes, PaginationResult } from "../types/non-generated";
|
||||
import { Requests } from "~~/lib/requests";
|
||||
|
||||
export type ItemsQuery = {
|
||||
includeArchived?: boolean;
|
||||
|
@ -12,7 +23,65 @@ export type ItemsQuery = {
|
|||
q?: string;
|
||||
};
|
||||
|
||||
export class AttachmentsAPI extends BaseAPI {
|
||||
add(id: string, file: File | Blob, filename: string, type: AttachmentTypes) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("type", type);
|
||||
formData.append("name", filename);
|
||||
|
||||
return this.http.post<FormData, ItemOut>({
|
||||
url: route(`/items/${id}/attachments`),
|
||||
data: formData,
|
||||
});
|
||||
}
|
||||
|
||||
delete(id: string, attachmentId: string) {
|
||||
return this.http.delete<void>({ url: route(`/items/${id}/attachments/${attachmentId}`) });
|
||||
}
|
||||
|
||||
update(id: string, attachmentId: string, data: ItemAttachmentUpdate) {
|
||||
return this.http.put<ItemAttachmentUpdate, ItemOut>({
|
||||
url: route(`/items/${id}/attachments/${attachmentId}`),
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class MaintenanceAPI extends BaseAPI {
|
||||
getLog(itemId: string) {
|
||||
return this.http.get<MaintenanceLog>({ url: route(`/items/${itemId}/maintenance`) });
|
||||
}
|
||||
|
||||
create(itemId: string, data: MaintenanceEntryCreate) {
|
||||
return this.http.post<MaintenanceEntryCreate, MaintenanceEntry>({
|
||||
url: route(`/items/${itemId}/maintenance`),
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
delete(itemId: string, entryId: string) {
|
||||
return this.http.delete<void>({ url: route(`/items/${itemId}/maintenance/${entryId}`) });
|
||||
}
|
||||
|
||||
update(itemId: string, entryId: string, data: MaintenanceEntryUpdate) {
|
||||
return this.http.put<MaintenanceEntryUpdate, MaintenanceEntry>({
|
||||
url: route(`/items/${itemId}/maintenance/${entryId}`),
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ItemsApi extends BaseAPI {
|
||||
attachments: AttachmentsAPI;
|
||||
maintenance: MaintenanceAPI;
|
||||
|
||||
constructor(http: Requests, token: string) {
|
||||
super(http, token);
|
||||
this.attachments = new AttachmentsAPI(http);
|
||||
this.maintenance = new MaintenanceAPI(http);
|
||||
}
|
||||
|
||||
getAll(q: ItemsQuery = {}) {
|
||||
return this.http.get<PaginationResult<ItemSummary>>({ url: route("/items", q) });
|
||||
}
|
||||
|
@ -59,27 +128,4 @@ export class ItemsApi extends BaseAPI {
|
|||
data: formData,
|
||||
});
|
||||
}
|
||||
|
||||
addAttachment(id: string, file: File | Blob, filename: string, type: AttachmentTypes) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("type", type);
|
||||
formData.append("name", filename);
|
||||
|
||||
return this.http.post<FormData, ItemOut>({
|
||||
url: route(`/items/${id}/attachments`),
|
||||
data: formData,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAttachment(id: string, attachmentId: string) {
|
||||
return await this.http.delete<void>({ url: route(`/items/${id}/attachments/${attachmentId}`) });
|
||||
}
|
||||
|
||||
async updateAttachment(id: string, attachmentId: string, data: ItemAttachmentUpdate) {
|
||||
return await this.http.put<ItemAttachmentUpdate, ItemOut>({
|
||||
url: route(`/items/${id}/attachments/${attachmentId}`),
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -54,7 +54,6 @@ export interface ItemAttachmentUpdate {
|
|||
export interface ItemCreate {
|
||||
description: string;
|
||||
labelIds: string[];
|
||||
|
||||
/** Edges */
|
||||
locationId: string;
|
||||
name: string;
|
||||
|
@ -73,8 +72,7 @@ export interface ItemField {
|
|||
|
||||
export interface ItemOut {
|
||||
archived: boolean;
|
||||
|
||||
/** @example 0 */
|
||||
/** @example "0" */
|
||||
assetId: string;
|
||||
attachments: ItemAttachment[];
|
||||
children: ItemSummary[];
|
||||
|
@ -84,33 +82,26 @@ export interface ItemOut {
|
|||
id: string;
|
||||
insured: boolean;
|
||||
labels: LabelSummary[];
|
||||
|
||||
/** Warranty */
|
||||
lifetimeWarranty: boolean;
|
||||
|
||||
/** Edges */
|
||||
location: LocationSummary | null;
|
||||
manufacturer: string;
|
||||
modelNumber: string;
|
||||
name: string;
|
||||
|
||||
/** Extras */
|
||||
notes: string;
|
||||
parent: ItemSummary | null;
|
||||
purchaseFrom: string;
|
||||
|
||||
/** @example 0 */
|
||||
/** @example "0" */
|
||||
purchasePrice: string;
|
||||
|
||||
/** Purchase */
|
||||
purchaseTime: Date;
|
||||
quantity: number;
|
||||
serialNumber: string;
|
||||
soldNotes: string;
|
||||
|
||||
/** @example 0 */
|
||||
/** @example "0" */
|
||||
soldPrice: string;
|
||||
|
||||
/** Sold */
|
||||
soldTime: Date;
|
||||
soldTo: string;
|
||||
|
@ -126,7 +117,6 @@ export interface ItemSummary {
|
|||
id: string;
|
||||
insured: boolean;
|
||||
labels: LabelSummary[];
|
||||
|
||||
/** Edges */
|
||||
location: LocationSummary | null;
|
||||
name: string;
|
||||
|
@ -142,35 +132,27 @@ export interface ItemUpdate {
|
|||
id: string;
|
||||
insured: boolean;
|
||||
labelIds: string[];
|
||||
|
||||
/** Warranty */
|
||||
lifetimeWarranty: boolean;
|
||||
|
||||
/** Edges */
|
||||
locationId: string;
|
||||
manufacturer: string;
|
||||
modelNumber: string;
|
||||
name: string;
|
||||
|
||||
/** Extras */
|
||||
notes: string;
|
||||
parentId: string | null;
|
||||
purchaseFrom: string;
|
||||
|
||||
/** @example 0 */
|
||||
/** @example "0" */
|
||||
purchasePrice: string;
|
||||
|
||||
/** Purchase */
|
||||
purchaseTime: Date;
|
||||
quantity: number;
|
||||
|
||||
/** Identifications */
|
||||
serialNumber: string;
|
||||
soldNotes: string;
|
||||
|
||||
/** @example 0 */
|
||||
/** @example "0" */
|
||||
soldPrice: string;
|
||||
|
||||
/** Sold */
|
||||
soldTime: Date;
|
||||
soldTo: string;
|
||||
|
@ -241,6 +223,38 @@ export interface LocationUpdate {
|
|||
parentId: string | null;
|
||||
}
|
||||
|
||||
export interface MaintenanceEntry {
|
||||
/** @example "0" */
|
||||
cost: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MaintenanceEntryCreate {
|
||||
/** @example "0" */
|
||||
cost: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MaintenanceEntryUpdate {
|
||||
/** @example "0" */
|
||||
cost: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MaintenanceLog {
|
||||
costAverage: number;
|
||||
costTotal: number;
|
||||
entries: MaintenanceEntry[];
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
export interface PaginationResultRepoItemSummary {
|
||||
items: ItemSummary[];
|
||||
page: number;
|
||||
|
@ -278,7 +292,7 @@ export interface ValueOverTime {
|
|||
}
|
||||
|
||||
export interface ValueOverTimeEntry {
|
||||
date: string;
|
||||
date: Date;
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
|
|
@ -1,20 +1,16 @@
|
|||
import { defineNuxtConfig } from "nuxt";
|
||||
import { defineNuxtConfig } from "nuxt/config";
|
||||
|
||||
// https://v3.nuxtjs.org/api/configuration/nuxt.config
|
||||
export default defineNuxtConfig({
|
||||
target: "static",
|
||||
ssr: false,
|
||||
modules: ["@nuxtjs/tailwindcss", "@pinia/nuxt", "@vueuse/nuxt"],
|
||||
meta: {
|
||||
title: "Homebox",
|
||||
link: [{ rel: "icon", type: "image/x-icon", href: "/favicon.svg" }],
|
||||
},
|
||||
vite: {
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:7745",
|
||||
},
|
||||
nitro: {
|
||||
devProxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:7745/api",
|
||||
changeOrigin: true,
|
||||
}
|
||||
},
|
||||
plugins: [],
|
||||
},
|
||||
plugins: [],
|
||||
});
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-vue": "^9.4.0",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
"nuxt": "3.0.0-rc.11",
|
||||
"nuxt": "3.0.0",
|
||||
"prettier": "^2.7.1",
|
||||
"typescript": "^4.8.3",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
|
@ -29,7 +29,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@iconify/vue": "^3.2.1",
|
||||
"@nuxtjs/tailwindcss": "^5.3.2",
|
||||
"@nuxtjs/tailwindcss": "^6.1.3",
|
||||
"@pinia/nuxt": "^0.4.1",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.0",
|
||||
"@tailwindcss/forms": "^0.5.2",
|
||||
|
|
|
@ -46,7 +46,7 @@
|
|||
const importDialog = ref(false);
|
||||
const importCsv = ref(null);
|
||||
const importLoading = ref(false);
|
||||
const importRef = ref<HTMLInputElement>(null);
|
||||
const importRef = ref<HTMLInputElement>();
|
||||
whenever(
|
||||
() => !importDialog.value,
|
||||
() => {
|
||||
|
@ -120,7 +120,7 @@
|
|||
<section>
|
||||
<BaseCard>
|
||||
<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>
|
||||
<div class="flex justify-end gap-2">
|
||||
<div class="tooltip" data-tip="Import CSV File">
|
||||
|
|
|
@ -214,7 +214,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await api.items.addAttachment(itemId.value, files[0], files[0].name, type);
|
||||
const { data, error } = await api.items.attachments.add(itemId.value, files[0], files[0].name, type);
|
||||
|
||||
if (error) {
|
||||
toast.error("Failed to upload attachment");
|
||||
|
@ -235,7 +235,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
const { error } = await api.items.deleteAttachment(itemId.value, attachmentId);
|
||||
const { error } = await api.items.attachments.delete(itemId.value, attachmentId);
|
||||
|
||||
if (error) {
|
||||
toast.error("Failed to delete attachment");
|
||||
|
@ -273,7 +273,7 @@
|
|||
|
||||
async function updateAttachment() {
|
||||
editState.loading = true;
|
||||
const { error, data } = await api.items.updateAttachment(itemId.value, editState.id, {
|
||||
const { error, data } = await api.items.attachments.update(itemId.value, editState.id, {
|
||||
title: editState.title,
|
||||
type: editState.type,
|
||||
});
|
||||
|
|
|
@ -13,6 +13,10 @@
|
|||
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) {
|
||||
|
@ -219,7 +223,7 @@
|
|||
} else {
|
||||
details.push({
|
||||
name: "Warranty Expires",
|
||||
text: item.value?.warrantyExpires,
|
||||
text: item.value?.warrantyExpires || "",
|
||||
type: "date",
|
||||
});
|
||||
}
|
||||
|
@ -253,7 +257,7 @@
|
|||
},
|
||||
{
|
||||
name: "Purchase Date",
|
||||
text: item.value.purchaseTime,
|
||||
text: item.value?.purchaseTime || "",
|
||||
type: "date",
|
||||
},
|
||||
];
|
||||
|
@ -309,12 +313,12 @@
|
|||
});
|
||||
|
||||
function openDialog(img: Photo) {
|
||||
refDialog.value.showModal();
|
||||
refDialog.value?.showModal();
|
||||
dialoged.src = img.src;
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
refDialog.value.close();
|
||||
refDialog.value?.close();
|
||||
}
|
||||
|
||||
const refDialogBody = ref<HTMLDivElement>();
|
||||
|
@ -340,10 +344,7 @@
|
|||
</div>
|
||||
</dialog>
|
||||
<section class="px-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="form-control"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<div class="space-y-3">
|
||||
<BaseCard>
|
||||
<template #title>
|
||||
<BaseSectionHeader>
|
||||
|
@ -374,10 +375,16 @@
|
|||
</template>
|
||||
<template #title-actions>
|
||||
<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" />
|
||||
<span class="label-text ml-4"> Show Empty </span>
|
||||
</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`">
|
||||
<template #icon>
|
||||
<Icon name="mdi-pencil" />
|
||||
|
@ -390,75 +397,84 @@
|
|||
</template>
|
||||
Delete
|
||||
</BaseButton>
|
||||
<BaseButton size="sm" :to="`/item/${itemId}/log`">
|
||||
<template #icon>
|
||||
<Icon name="mdi-post" />
|
||||
</template>
|
||||
Log
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<DetailsSection :details="itemDetails" />
|
||||
<DetailsSection v-if="!hasNested" :details="itemDetails" />
|
||||
</BaseCard>
|
||||
|
||||
<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>
|
||||
<NuxtPage :item="item" :page-key="itemId" />
|
||||
<div 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">
|
||||
<template #title> Attachments </template>
|
||||
<DetailsSection :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>
|
||||
</BaseCard>
|
||||
<BaseCard v-if="showAttachments">
|
||||
<template #title> Attachments </template>
|
||||
<DetailsSection :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>
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard v-if="showPurchase">
|
||||
<template #title> Purchase Details </template>
|
||||
<DetailsSection :details="purchaseDetails" />
|
||||
</BaseCard>
|
||||
<BaseCard v-if="showPurchase">
|
||||
<template #title> Purchase Details </template>
|
||||
<DetailsSection :details="purchaseDetails" />
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard v-if="showWarranty">
|
||||
<template #title> Warranty Details </template>
|
||||
<DetailsSection :details="warrantyDetails" />
|
||||
</BaseCard>
|
||||
<BaseCard v-if="showWarranty">
|
||||
<template #title> Warranty Details </template>
|
||||
<DetailsSection :details="warrantyDetails" />
|
||||
</BaseCard>
|
||||
|
||||
<BaseCard v-if="showSold">
|
||||
<template #title> Sold Details </template>
|
||||
<DetailsSection :details="soldDetails" />
|
||||
</BaseCard>
|
||||
<BaseCard v-if="showSold">
|
||||
<template #title> Sold Details </template>
|
||||
<DetailsSection :details="soldDetails" />
|
||||
</BaseCard>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<ItemCard v-for="child in item.children" :key="child.id" :item="child" />
|
||||
|
|
173
frontend/pages/item/[id]/index/log.vue
Normal file
173
frontend/pages/item/[id]/index/log.vue
Normal 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
1247
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue