mirror of
https://github.com/hay-kot/homebox.git
synced 2025-07-06 18:48:34 +00:00
feat: item-attachments CRUD (#22)
* change /content/ -> /homebox/ * add cache to code generators * update env variables to set data storage * update env variables * set env variables in prod container * implement attachment post route (WIP) * get attachment endpoint * attachment download * implement string utilities lib * implement generic drop zone * use explicit truncate * remove clean dir * drop strings composable for lib * update item types and add attachments * add attachment API * implement service context * consolidate API code * implement editing attachments * implement upload limit configuration * improve error handling * add docs for max upload size * fix test cases
This commit is contained in:
parent
852d312ba7
commit
31b34241e0
165 changed files with 2509 additions and 664 deletions
|
@ -25,7 +25,7 @@
|
|||
},
|
||||
modelValue: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type: Object as any,
|
||||
type: [Object, String, Boolean] as any,
|
||||
default: null,
|
||||
},
|
||||
items: {
|
||||
|
@ -37,18 +37,47 @@
|
|||
type: String,
|
||||
default: "name",
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
selectFirst: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
watchOnce(
|
||||
() => props.items,
|
||||
() => {
|
||||
if (props.selectFirst && props.items.length > 0) {
|
||||
function syncSelect() {
|
||||
if (!props.modelValue) {
|
||||
if (props.selectFirst) {
|
||||
selectedIdx.value = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Check if we're already synced
|
||||
if (props.value) {
|
||||
if (props.modelValue[props.value] === props.items[selectedIdx.value][props.value]) {
|
||||
return;
|
||||
}
|
||||
} else if (props.modelValue === props.items[selectedIdx.value]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = props.items.findIndex(item => {
|
||||
if (props.value) {
|
||||
return item[props.value] === props.modelValue;
|
||||
}
|
||||
return item === props.modelValue;
|
||||
});
|
||||
|
||||
selectedIdx.value = idx;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
syncSelect();
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -56,6 +85,10 @@
|
|||
watch(
|
||||
() => selectedIdx.value,
|
||||
() => {
|
||||
if (props.value) {
|
||||
emit("update:modelValue", props.items[selectedIdx.value][props.value]);
|
||||
return;
|
||||
}
|
||||
emit("update:modelValue", props.items[selectedIdx.value]);
|
||||
}
|
||||
);
|
||||
|
|
56
frontend/components/Item/AttachmentsList.vue
Normal file
56
frontend/components/Item/AttachmentsList.vue
Normal file
|
@ -0,0 +1,56 @@
|
|||
<template>
|
||||
<ul role="list" class="divide-y divide-gray-400 rounded-md border border-gray-400">
|
||||
<li
|
||||
v-for="attachment in attachments"
|
||||
:key="attachment.id"
|
||||
class="flex items-center justify-between py-3 pl-3 pr-4 text-sm"
|
||||
>
|
||||
<div class="flex w-0 flex-1 items-center">
|
||||
<Icon name="mdi-paperclip" class="h-5 w-5 flex-shrink-0 text-gray-400" aria-hidden="true" />
|
||||
<span class="ml-2 w-0 flex-1 truncate"> {{ attachment.document.title }}</span>
|
||||
</div>
|
||||
<div class="ml-4 flex-shrink-0">
|
||||
<button class="font-medium" @click="getAttachmentUrl(attachment)">Download</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ItemAttachment } from "~~/lib/api/types/data-contracts";
|
||||
|
||||
const props = defineProps({
|
||||
attachments: {
|
||||
type: Object as () => ItemAttachment[],
|
||||
required: true,
|
||||
},
|
||||
itemId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const api = useUserApi();
|
||||
const toast = useNotifier();
|
||||
async function getAttachmentUrl(attachment: ItemAttachment) {
|
||||
const url = await api.items.getAttachmentUrl(props.itemId, attachment.id);
|
||||
|
||||
if (!url) {
|
||||
toast.error("Failed to get attachment url");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!document) {
|
||||
window.open(url, "_blank");
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.target = "_blank";
|
||||
link.setAttribute("download", attachment.document.title);
|
||||
link.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -23,6 +23,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { ItemOut, ItemSummary } from "~~/lib/api/types/data-contracts";
|
||||
import { truncate } from "~~/lib/strings";
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
|
|
31
frontend/components/global/DropZone.vue
Normal file
31
frontend/components/global/DropZone.vue
Normal file
|
@ -0,0 +1,31 @@
|
|||
<template>
|
||||
<div
|
||||
ref="el"
|
||||
class="h-24 w-full border-2 border-primary border-dashed grid place-content-center"
|
||||
:class="isOverDropZone ? 'bg-primary bg-opacity-10' : ''"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "drop"]);
|
||||
|
||||
const el = ref<HTMLDivElement>(null);
|
||||
const { isOverDropZone } = useDropZone(el, files => {
|
||||
emit("drop", files);
|
||||
});
|
||||
|
||||
watch(isOverDropZone, () => {
|
||||
emit("update:modelValue", isOverDropZone.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -1,7 +0,0 @@
|
|||
export function truncate(str: string, length: number) {
|
||||
return str.length > length ? str.substring(0, length) + "..." : str;
|
||||
}
|
||||
|
||||
export function capitalize(str: string) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
61
frontend/lib/api/__test__/user/items.test.ts
Normal file
61
frontend/lib/api/__test__/user/items.test.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { describe, test, expect } from "vitest";
|
||||
import { LocationOut } from "../../types/data-contracts";
|
||||
import { AttachmentTypes } from "../../types/non-generated";
|
||||
import { UserApi } from "../../user";
|
||||
import { sharedUserClient } from "../test-utils";
|
||||
|
||||
describe("user should be able to create an item and add an attachment", () => {
|
||||
let increment = 0;
|
||||
/**
|
||||
* 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<[LocationOut, () => Promise<void>]> {
|
||||
const { response, data } = await api.locations.create({
|
||||
name: `__test__.location.name_${increment}`,
|
||||
description: `__test__.location.description_${increment}`,
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
increment++;
|
||||
|
||||
const cleanup = async () => {
|
||||
const { response } = await api.locations.delete(data.id);
|
||||
expect(response.status).toBe(204);
|
||||
};
|
||||
|
||||
return [data, cleanup];
|
||||
}
|
||||
|
||||
test("user should be able to create an item and add an attachment", async () => {
|
||||
const api = await sharedUserClient();
|
||||
const [location, cleanup] = await useLocation(api);
|
||||
|
||||
const { response, data: item } = await api.items.create({
|
||||
name: "test-item",
|
||||
labelIds: [],
|
||||
description: "test-description",
|
||||
locationId: location.id,
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
// Add attachment
|
||||
{
|
||||
const testFile = new Blob(["test"], { type: "text/plain" });
|
||||
const { response } = await api.items.addAttachment(item.id, testFile, "test.txt", AttachmentTypes.Attachment);
|
||||
expect(response.status).toBe(201);
|
||||
}
|
||||
|
||||
// Get Attachment
|
||||
const { response: itmResp, data } = await api.items.get(item.id);
|
||||
expect(itmResp.status).toBe(200);
|
||||
|
||||
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);
|
||||
expect(resp.response.status).toBe(204);
|
||||
|
||||
api.items.delete(item.id);
|
||||
await cleanup();
|
||||
});
|
||||
});
|
|
@ -1,15 +1,23 @@
|
|||
import { BaseAPI, route } from "../base";
|
||||
import { parseDate } from "../base/base-api";
|
||||
import { ItemCreate, ItemOut, ItemSummary, ItemUpdate } from "../types/data-contracts";
|
||||
import {
|
||||
ItemAttachmentToken,
|
||||
ItemAttachmentUpdate,
|
||||
ItemCreate,
|
||||
ItemOut,
|
||||
ItemSummary,
|
||||
ItemUpdate,
|
||||
} from "../types/data-contracts";
|
||||
import { AttachmentTypes } from "../types/non-generated";
|
||||
import { Results } from "./types";
|
||||
|
||||
export class ItemsApi extends BaseAPI {
|
||||
getAll() {
|
||||
return this.http.get<Results<ItemOut>>({ url: route("/items") });
|
||||
return this.http.get<Results<ItemSummary>>({ url: route("/items") });
|
||||
}
|
||||
|
||||
create(item: ItemCreate) {
|
||||
return this.http.post<ItemCreate, ItemSummary>({ url: route("/items"), body: item });
|
||||
return this.http.post<ItemCreate, ItemOut>({ url: route("/items"), body: item });
|
||||
}
|
||||
|
||||
async get(id: string) {
|
||||
|
@ -45,6 +53,44 @@ export class ItemsApi extends BaseAPI {
|
|||
const formData = new FormData();
|
||||
formData.append("csv", file);
|
||||
|
||||
return this.http.post<FormData, void>({ url: route("/items/import"), data: formData });
|
||||
return this.http.post<FormData, void>({
|
||||
url: route("/items/import"),
|
||||
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 getAttachmentUrl(id: string, attachmentId: string): Promise<string> {
|
||||
const payload = await this.http.get<ItemAttachmentToken>({
|
||||
url: route(`/items/${id}/attachments/${attachmentId}`),
|
||||
});
|
||||
|
||||
if (!payload.data) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return route(`/items/${id}/attachments/download`, { token: payload.data.token });
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,6 +21,11 @@ export interface ServerResults {
|
|||
items: any;
|
||||
}
|
||||
|
||||
export interface ServerValidationError {
|
||||
field: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ApiSummary {
|
||||
build: Build;
|
||||
health: boolean;
|
||||
|
@ -45,9 +50,19 @@ export interface ItemAttachment {
|
|||
createdAt: Date;
|
||||
document: DocumentOut;
|
||||
id: string;
|
||||
type: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ItemAttachmentToken {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface ItemAttachmentUpdate {
|
||||
title: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ItemCreate {
|
||||
description: string;
|
||||
labelIds: string[];
|
||||
|
@ -191,7 +206,6 @@ export interface LabelCreate {
|
|||
export interface LabelOut {
|
||||
createdAt: Date;
|
||||
description: string;
|
||||
groupId: string;
|
||||
id: string;
|
||||
items: ItemSummary[];
|
||||
name: string;
|
||||
|
@ -201,7 +215,6 @@ export interface LabelOut {
|
|||
export interface LabelSummary {
|
||||
createdAt: Date;
|
||||
description: string;
|
||||
groupId: string;
|
||||
id: string;
|
||||
name: string;
|
||||
updatedAt: Date;
|
||||
|
|
6
frontend/lib/api/types/non-generated.ts
Normal file
6
frontend/lib/api/types/non-generated.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export enum AttachmentTypes {
|
||||
Photo = "photo",
|
||||
Manual = "manual",
|
||||
Warranty = "warranty",
|
||||
Attachment = "attachment",
|
||||
}
|
|
@ -97,11 +97,15 @@ export class Requests {
|
|||
return {} as T;
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (e) {
|
||||
return {} as T;
|
||||
if (response.headers.get("Content-Type")?.startsWith("application/json")) {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (e) {
|
||||
return {} as T;
|
||||
}
|
||||
}
|
||||
|
||||
return response.body as unknown as T;
|
||||
})();
|
||||
|
||||
return {
|
||||
|
|
56
frontend/lib/strings/index.test.ts
Normal file
56
frontend/lib/strings/index.test.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { titlecase, capitalize, truncate } from ".";
|
||||
|
||||
describe("title case tests", () => {
|
||||
it("should return the same string if it's already title case", () => {
|
||||
expect(titlecase("Hello World")).toBe("Hello World");
|
||||
});
|
||||
|
||||
it("should title case a lower case word", () => {
|
||||
expect(titlecase("hello")).toBe("Hello");
|
||||
});
|
||||
|
||||
it("should title case a sentence", () => {
|
||||
expect(titlecase("hello world")).toBe("Hello World");
|
||||
});
|
||||
|
||||
it("should title case a sentence with multiple words", () => {
|
||||
expect(titlecase("hello world this is a test")).toBe("Hello World This Is A Test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("capitilize tests", () => {
|
||||
it("should return the same string if it's already capitalized", () => {
|
||||
expect(capitalize("Hello")).toBe("Hello");
|
||||
});
|
||||
|
||||
it("should capitalize a lower case word", () => {
|
||||
expect(capitalize("hello")).toBe("Hello");
|
||||
});
|
||||
|
||||
it("should capitalize a sentence", () => {
|
||||
expect(capitalize("hello world")).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("should capitalize a sentence with multiple words", () => {
|
||||
expect(capitalize("hello world this is a test")).toBe("Hello world this is a test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncase tests", () => {
|
||||
it("should return the same string if it's already truncated", () => {
|
||||
expect(truncate("Hello", 5)).toBe("Hello");
|
||||
});
|
||||
|
||||
it("should truncate a lower case word", () => {
|
||||
expect(truncate("hello", 3)).toBe("hel...");
|
||||
});
|
||||
|
||||
it("should truncate a sentence", () => {
|
||||
expect(truncate("hello world", 5)).toBe("hello...");
|
||||
});
|
||||
|
||||
it("should truncate a sentence with multiple words", () => {
|
||||
expect(truncate("hello world this is a test", 10)).toBe("hello worl...");
|
||||
});
|
||||
});
|
14
frontend/lib/strings/index.ts
Normal file
14
frontend/lib/strings/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
export function titlecase(str: string) {
|
||||
return str
|
||||
.split(" ")
|
||||
.map(word => word[0].toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function capitalize(str: string) {
|
||||
return str[0].toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
export function truncate(str: string, length: number) {
|
||||
return str.length > length ? str.substring(0, length) + "..." : str;
|
||||
}
|
|
@ -121,7 +121,7 @@
|
|||
<div class="sm:flex sm:space-x-5">
|
||||
<div class="mt-4 text-center sm:mt-0 sm:pt-1 sm:text-left">
|
||||
<p class="text-sm font-medium text-gray-600">Welcome back,</p>
|
||||
<p class="text-xl font-bold text-gray-900 sm:text-2xl">Hayden Kotelman</p>
|
||||
<p class="text-xl font-bold text-gray-900 sm:text-2xl">Username</p>
|
||||
<p class="text-sm font-medium text-gray-600">User</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import { ItemUpdate } from "~~/lib/api/types/data-contracts";
|
||||
import { ItemAttachment, ItemUpdate } from "~~/lib/api/types/data-contracts";
|
||||
import { AttachmentTypes } from "~~/lib/api/types/non-generated";
|
||||
import { useLabelStore } from "~~/stores/labels";
|
||||
import { useLocationStore } from "~~/stores/locations";
|
||||
import { capitalize } from "~~/lib/strings";
|
||||
|
||||
definePageMeta({
|
||||
layout: "home",
|
||||
|
@ -154,10 +156,131 @@
|
|||
ref: "soldTime",
|
||||
},
|
||||
];
|
||||
|
||||
// - Attachments
|
||||
const attDropZone = ref<HTMLDivElement>();
|
||||
const { isOverDropZone: attDropZoneActive } = useDropZone(attDropZone);
|
||||
|
||||
const refAttachmentInput = ref<HTMLInputElement>();
|
||||
|
||||
function clickUpload() {
|
||||
if (!refAttachmentInput.value) {
|
||||
return;
|
||||
}
|
||||
refAttachmentInput.value.click();
|
||||
}
|
||||
|
||||
function uploadImage(e: InputEvent) {
|
||||
const files = (e.target as HTMLInputElement).files;
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
|
||||
uploadAttachment([files.item(0)], AttachmentTypes.Attachment);
|
||||
}
|
||||
|
||||
const dropPhoto = (files: File[] | null) => uploadAttachment(files, AttachmentTypes.Photo);
|
||||
const dropAttachment = (files: File[] | null) => uploadAttachment(files, AttachmentTypes.Attachment);
|
||||
const dropWarranty = (files: File[] | null) => uploadAttachment(files, AttachmentTypes.Warranty);
|
||||
const dropManual = (files: File[] | null) => uploadAttachment(files, AttachmentTypes.Manual);
|
||||
|
||||
async function uploadAttachment(files: File[] | null, type: AttachmentTypes) {
|
||||
if (!files && files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await api.items.addAttachment(itemId.value, files[0], files[0].name, type);
|
||||
|
||||
if (error) {
|
||||
toast.error("Failed to upload attachment");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Attachment uploaded");
|
||||
|
||||
item.value.attachments = data.attachments;
|
||||
}
|
||||
|
||||
const confirm = useConfirm();
|
||||
|
||||
async function deleteAttachment(attachmentId: string) {
|
||||
const confirmed = await confirm.reveal("Are you sure you want to delete this attachment?");
|
||||
|
||||
if (confirmed.isCanceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await api.items.deleteAttachment(itemId.value, attachmentId);
|
||||
|
||||
if (error) {
|
||||
toast.error("Failed to delete attachment");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Attachment deleted");
|
||||
item.value.attachments = item.value.attachments.filter(a => a.id !== attachmentId);
|
||||
}
|
||||
|
||||
const editState = reactive({
|
||||
modal: false,
|
||||
loading: false,
|
||||
|
||||
// Values
|
||||
id: "",
|
||||
title: "",
|
||||
type: "",
|
||||
});
|
||||
|
||||
const attachmentOpts = Object.entries(AttachmentTypes).map(([key, value]) => ({
|
||||
text: capitalize(key),
|
||||
value,
|
||||
}));
|
||||
|
||||
function openAttachmentEditDialog(attachment: ItemAttachment) {
|
||||
editState.id = attachment.id;
|
||||
editState.title = attachment.document.title;
|
||||
editState.type = attachment.type;
|
||||
editState.modal = true;
|
||||
}
|
||||
|
||||
async function updateAttachment() {
|
||||
editState.loading = true;
|
||||
|
||||
const { error, data } = await api.items.updateAttachment(itemId.value, editState.id, {
|
||||
title: editState.title,
|
||||
type: editState.type,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error("Failed to update attachment");
|
||||
return;
|
||||
}
|
||||
|
||||
item.value.attachments = data.attachments;
|
||||
|
||||
editState.loading = false;
|
||||
editState.modal = false;
|
||||
|
||||
editState.id = "";
|
||||
editState.title = "";
|
||||
editState.type = "";
|
||||
|
||||
toast.success("Attachment updated");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseContainer v-if="item" class="pb-8">
|
||||
<BaseModal v-model="editState.modal">
|
||||
<template #title> Attachment Edit </template>
|
||||
|
||||
<FormTextField v-model="editState.title" label="Attachment Title" />
|
||||
<FormSelect v-model="editState.type" label="Attachment Type" value="value" name="text" :items="attachmentOpts" />
|
||||
<div class="modal-action">
|
||||
<BaseButton :loading="editState.loading" @click="updateAttachment"> Update </BaseButton>
|
||||
</div>
|
||||
</BaseModal>
|
||||
|
||||
<section class="px-3">
|
||||
<div class="space-y-4">
|
||||
<div class="overflow-hidden card bg-base-100 shadow-xl sm:rounded-lg">
|
||||
|
@ -223,6 +346,62 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!preferences.editorSimpleView"
|
||||
ref="attDropZone"
|
||||
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">Attachments</h3>
|
||||
<p class="text-xs">Changes to attachments will be saved immediately</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-300 p-4">
|
||||
<div v-if="attDropZoneActive" class="grid grid-cols-4 gap-4">
|
||||
<DropZone @drop="dropPhoto"> Photo </DropZone>
|
||||
<DropZone @drop="dropWarranty"> Warranty </DropZone>
|
||||
<DropZone @drop="dropManual"> Manual </DropZone>
|
||||
<DropZone @drop="dropAttachment"> Attachment </DropZone>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="h-24 w-full border-2 border-primary border-dashed grid place-content-center"
|
||||
@click="clickUpload"
|
||||
>
|
||||
<input ref="refAttachmentInput" hidden type="file" @change="uploadImage" />
|
||||
<p>Drag and drop files here or click to select files</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-300 p-4">
|
||||
<ul role="list" class="divide-y divide-gray-400 rounded-md border border-gray-400">
|
||||
<li
|
||||
v-for="attachment in item.attachments"
|
||||
:key="attachment.id"
|
||||
class="grid grid-cols-6 justify-between py-3 pl-3 pr-4 text-sm"
|
||||
>
|
||||
<p class="my-auto col-span-4">
|
||||
{{ attachment.document.title }}
|
||||
</p>
|
||||
<p class="my-auto">
|
||||
{{ capitalize(attachment.type) }}
|
||||
</p>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<div class="tooltip" data-tip="Delete">
|
||||
<button class="btn btn-sm btn-square" @click="deleteAttachment(attachment.id)">
|
||||
<Icon name="mdi-delete" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip" data-tip="Edit">
|
||||
<button class="btn btn-sm btn-square" @click="openAttachmentEditDialog(attachment)">
|
||||
<Icon name="mdi-pencil" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</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">Purchase Details</h3>
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { ItemAttachment } from "~~/lib/api/types/data-contracts";
|
||||
|
||||
definePageMeta({
|
||||
layout: "home",
|
||||
});
|
||||
|
@ -23,6 +25,45 @@
|
|||
refresh();
|
||||
});
|
||||
|
||||
type FilteredAttachments = {
|
||||
photos: ItemAttachment[];
|
||||
attachments: ItemAttachment[];
|
||||
warranty: ItemAttachment[];
|
||||
manuals: ItemAttachment[];
|
||||
};
|
||||
|
||||
const attachments = computed<FilteredAttachments>(() => {
|
||||
if (!item.value) {
|
||||
return {
|
||||
photos: [],
|
||||
attachments: [],
|
||||
manuals: [],
|
||||
warranty: [],
|
||||
};
|
||||
}
|
||||
|
||||
return item.value.attachments.reduce(
|
||||
(acc, attachment) => {
|
||||
if (attachment.type === "photo") {
|
||||
acc.photos.push(attachment);
|
||||
} else if (attachment.type === "warranty") {
|
||||
acc.warranty.push(attachment);
|
||||
} else if (attachment.type === "manual") {
|
||||
acc.manuals.push(attachment);
|
||||
} else {
|
||||
acc.attachments.push(attachment);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
photos: [] as ItemAttachment[],
|
||||
attachments: [] as ItemAttachment[],
|
||||
warranty: [] as ItemAttachment[],
|
||||
manuals: [] as ItemAttachment[],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const itemSummary = computed(() => {
|
||||
return {
|
||||
Description: item.value?.description || "",
|
||||
|
@ -31,10 +72,53 @@
|
|||
Manufacturer: item.value?.manufacturer || "",
|
||||
Notes: item.value?.notes || "",
|
||||
Insured: item.value?.insured ? "Yes" : "No",
|
||||
Attachments: "", // TODO: Attachments
|
||||
};
|
||||
});
|
||||
|
||||
const showAttachments = computed(() => {
|
||||
if (preferences.value?.showEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
attachments.value.photos.length > 0 ||
|
||||
attachments.value.attachments.length > 0 ||
|
||||
attachments.value.warranty.length > 0 ||
|
||||
attachments.value.manuals.length > 0
|
||||
);
|
||||
});
|
||||
|
||||
const itemAttachments = computed(() => {
|
||||
const val: Record<string, string> = {};
|
||||
|
||||
if (preferences.value.showEmpty) {
|
||||
return {
|
||||
Photos: "",
|
||||
Manuals: "",
|
||||
Warranty: "",
|
||||
Attachments: "",
|
||||
};
|
||||
}
|
||||
|
||||
if (attachments.value.photos.length > 0) {
|
||||
val.Photos = "";
|
||||
}
|
||||
|
||||
if (attachments.value.manuals.length > 0) {
|
||||
val.Manuals = "";
|
||||
}
|
||||
|
||||
if (attachments.value.warranty.length > 0) {
|
||||
val.Warranty = "";
|
||||
}
|
||||
|
||||
if (attachments.value.attachments.length > 0) {
|
||||
val.Attachments = "";
|
||||
}
|
||||
|
||||
return val;
|
||||
});
|
||||
|
||||
const showWarranty = computed(() => {
|
||||
if (preferences.value.showEmpty) {
|
||||
return true;
|
||||
|
@ -148,27 +232,36 @@
|
|||
</template>
|
||||
</BaseSectionHeader>
|
||||
</template>
|
||||
</BaseDetails>
|
||||
<BaseDetails v-if="showAttachments" :details="itemAttachments">
|
||||
<template #title> Attachments </template>
|
||||
<template #Manuals>
|
||||
<ItemAttachmentsList
|
||||
v-if="attachments.manuals.length > 0"
|
||||
:attachments="attachments.manuals"
|
||||
:item-id="item.id"
|
||||
/>
|
||||
</template>
|
||||
<template #Attachments>
|
||||
<ul role="list" class="divide-y divide-gray-400 rounded-md border border-gray-400">
|
||||
<li class="flex items-center justify-between py-3 pl-3 pr-4 text-sm">
|
||||
<div class="flex w-0 flex-1 items-center">
|
||||
<Icon name="mdi-paperclip" class="h-5 w-5 flex-shrink-0 text-gray-400" aria-hidden="true" />
|
||||
<span class="ml-2 w-0 flex-1 truncate">User Guide.pdf</span>
|
||||
</div>
|
||||
<div class="ml-4 flex-shrink-0">
|
||||
<a href="#" class="font-medium">Download</a>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-center justify-between py-3 pl-3 pr-4 text-sm">
|
||||
<div class="flex w-0 flex-1 items-center">
|
||||
<Icon name="mdi-paperclip" class="h-5 w-5 flex-shrink-0 text-gray-400" aria-hidden="true" />
|
||||
<span class="ml-2 w-0 flex-1 truncate">Purchase Receipts.pdf</span>
|
||||
</div>
|
||||
<div class="ml-4 flex-shrink-0">
|
||||
<a href="#" class="font-medium">Download</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<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 #Photos>
|
||||
<ItemAttachmentsList
|
||||
v-if="attachments.photos.length > 0"
|
||||
:attachments="attachments.photos"
|
||||
:item-id="item.id"
|
||||
/>
|
||||
</template>
|
||||
</BaseDetails>
|
||||
<BaseDetails v-if="showPurchase" :details="purchaseDetails">
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue