mirror of
https://github.com/hay-kot/homebox.git
synced 2025-08-05 09:10:26 +00:00
implement editing attachments
This commit is contained in:
parent
d67d96c6fe
commit
fe819d0374
13 changed files with 352 additions and 24 deletions
|
@ -234,7 +234,7 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := services.NewServiceContext(r.Context())
|
ctx := services.NewContext(r.Context())
|
||||||
|
|
||||||
item, err := ctrl.svc.Items.AttachmentAdd(
|
item, err := ctrl.svc.Items.AttachmentAdd(
|
||||||
ctx,
|
ctx,
|
||||||
|
|
|
@ -91,7 +91,7 @@ func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := services.NewServiceContext(r.Context())
|
ctx := services.NewContext(r.Context())
|
||||||
|
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
|
|
||||||
|
@ -99,9 +99,27 @@ func (ctrl *V1Controller) handleItemAttachmentsHandler(w http.ResponseWriter, r
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
token, err := ctrl.svc.Items.AttachmentToken(ctx, uid, attachmentId)
|
token, err := ctrl.svc.Items.AttachmentToken(ctx, uid, attachmentId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Err(err).Msg("failed to get attachment")
|
switch err {
|
||||||
server.RespondServerError(w)
|
case services.ErrNotFound:
|
||||||
return
|
log.Err(err).
|
||||||
|
Str("id", attachmentId.String()).
|
||||||
|
Msg("failed to find attachment with id")
|
||||||
|
|
||||||
|
server.RespondError(w, http.StatusNotFound, err)
|
||||||
|
|
||||||
|
case services.ErrFileNotFound:
|
||||||
|
log.Err(err).
|
||||||
|
Str("id", attachmentId.String()).
|
||||||
|
Msg("failed to find file path for attachment with id")
|
||||||
|
log.Warn().Msg("attachment with no file path removed from database")
|
||||||
|
|
||||||
|
server.RespondError(w, http.StatusNotFound, err)
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.Err(err).Msg("failed to get attachment")
|
||||||
|
server.RespondServerError(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
server.Respond(w, http.StatusOK, types.ItemAttachmentToken{Token: token})
|
server.Respond(w, http.StatusOK, types.ItemAttachmentToken{Token: token})
|
||||||
|
|
|
@ -16,7 +16,7 @@ var (
|
||||||
ContextUserToken = &contextKeys{name: "UserToken"}
|
ContextUserToken = &contextKeys{name: "UserToken"}
|
||||||
)
|
)
|
||||||
|
|
||||||
type ServiceContext struct {
|
type Context struct {
|
||||||
context.Context
|
context.Context
|
||||||
|
|
||||||
// UID is a unique identifier for the acting user.
|
// UID is a unique identifier for the acting user.
|
||||||
|
@ -29,11 +29,11 @@ type ServiceContext struct {
|
||||||
User *types.UserOut
|
User *types.UserOut
|
||||||
}
|
}
|
||||||
|
|
||||||
// UseServiceCtx is a helper function that returns the service context from the context.
|
// NewContext is a helper function that returns the service context from the context.
|
||||||
// This extracts the users from the context and embeds it into the ServiceContext struct
|
// This extracts the users from the context and embeds it into the ServiceContext struct
|
||||||
func NewServiceContext(ctx context.Context) ServiceContext {
|
func NewContext(ctx context.Context) Context {
|
||||||
user := UseUserCtx(ctx)
|
user := UseUserCtx(ctx)
|
||||||
return ServiceContext{
|
return Context{
|
||||||
Context: ctx,
|
Context: ctx,
|
||||||
UID: user.ID,
|
UID: user.ID,
|
||||||
GID: user.GroupID,
|
GID: user.GroupID,
|
||||||
|
|
|
@ -13,7 +13,8 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrNotFound = errors.New("not found")
|
ErrNotFound = errors.New("not found")
|
||||||
|
ErrFileNotFound = errors.New("file not found")
|
||||||
)
|
)
|
||||||
|
|
||||||
type ItemService struct {
|
type ItemService struct {
|
||||||
|
|
|
@ -40,7 +40,7 @@ func (at attachmentTokens) Delete(token string) {
|
||||||
delete(at, token)
|
delete(at, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *ItemService) AttachmentToken(ctx ServiceContext, itemId, attachmentId uuid.UUID) (string, error) {
|
func (svc *ItemService) AttachmentToken(ctx Context, itemId, attachmentId uuid.UUID) (string, error) {
|
||||||
item, err := svc.repo.Items.GetOne(ctx, itemId)
|
item, err := svc.repo.Items.GetOne(ctx, itemId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
@ -51,6 +51,17 @@ func (svc *ItemService) AttachmentToken(ctx ServiceContext, itemId, attachmentId
|
||||||
|
|
||||||
token := hasher.GenerateToken()
|
token := hasher.GenerateToken()
|
||||||
|
|
||||||
|
// Ensure that the file exists
|
||||||
|
attachment, err := svc.repo.Attachments.Get(ctx, attachmentId)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(attachment.Edges.Document.Path); os.IsNotExist(err) {
|
||||||
|
svc.AttachmentDelete(ctx, ctx.GID, itemId, attachmentId)
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
svc.at.Add(token.Raw, attachmentId)
|
svc.at.Add(token.Raw, attachmentId)
|
||||||
|
|
||||||
return token.Raw, nil
|
return token.Raw, nil
|
||||||
|
@ -77,7 +88,7 @@ func (svc *ItemService) AttachmentPath(ctx context.Context, token string) (strin
|
||||||
return attachment.Edges.Document.Path, nil
|
return attachment.Edges.Document.Path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *ItemService) AttachmentUpdate(ctx ServiceContext, itemId uuid.UUID, data *types.ItemAttachmentUpdate) (*types.ItemOut, error) {
|
func (svc *ItemService) AttachmentUpdate(ctx Context, itemId uuid.UUID, data *types.ItemAttachmentUpdate) (*types.ItemOut, error) {
|
||||||
// Update Properties
|
// Update Properties
|
||||||
attachment, err := svc.repo.Attachments.Update(ctx, data.ID, attachment.Type(data.Type))
|
attachment, err := svc.repo.Attachments.Update(ctx, data.ID, attachment.Type(data.Type))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -110,7 +121,7 @@ func (svc *ItemService) AttachmentUpdate(ctx ServiceContext, itemId uuid.UUID, d
|
||||||
// AttachmentAdd adds an attachment to an item by creating an entry in the Documents table and linking it to the Attachment
|
// AttachmentAdd adds an attachment to an item by creating an entry in the Documents table and linking it to the Attachment
|
||||||
// Table and Items table. The file provided via the reader is stored on the file system based on the provided
|
// Table and Items table. The file provided via the reader is stored on the file system based on the provided
|
||||||
// relative path during construction of the service.
|
// relative path during construction of the service.
|
||||||
func (svc *ItemService) AttachmentAdd(ctx ServiceContext, itemId uuid.UUID, filename string, attachmentType attachment.Type, file io.Reader) (*types.ItemOut, error) {
|
func (svc *ItemService) AttachmentAdd(ctx Context, itemId uuid.UUID, filename string, attachmentType attachment.Type, file io.Reader) (*types.ItemOut, error) {
|
||||||
// Get the Item
|
// Get the Item
|
||||||
item, err := svc.repo.Items.GetOne(ctx, itemId)
|
item, err := svc.repo.Items.GetOne(ctx, itemId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -125,7 +125,7 @@ func TestItemService_AddAttachment(t *testing.T) {
|
||||||
reader := strings.NewReader(contents)
|
reader := strings.NewReader(contents)
|
||||||
|
|
||||||
// Setup
|
// Setup
|
||||||
afterAttachment, err := svc.AttachmentAdd(ServiceContext{Context: context.Background(), GID: tGroup.ID}, itm.ID, "testfile.txt", "attachment", reader)
|
afterAttachment, err := svc.AttachmentAdd(Context{Context: context.Background(), GID: tGroup.ID}, itm.ID, "testfile.txt", "attachment", reader)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, afterAttachment)
|
assert.NotNil(t, afterAttachment)
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,7 @@
|
||||||
},
|
},
|
||||||
modelValue: {
|
modelValue: {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
type: Object as any,
|
type: [Object, String, Boolean] as any,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
items: {
|
items: {
|
||||||
|
@ -37,18 +37,47 @@
|
||||||
type: String,
|
type: String,
|
||||||
default: "name",
|
default: "name",
|
||||||
},
|
},
|
||||||
|
value: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
selectFirst: {
|
selectFirst: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
watchOnce(
|
function syncSelect() {
|
||||||
() => props.items,
|
if (!props.modelValue) {
|
||||||
() => {
|
if (props.selectFirst) {
|
||||||
if (props.selectFirst && props.items.length > 0) {
|
|
||||||
selectedIdx.value = 0;
|
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(
|
watch(
|
||||||
() => selectedIdx.value,
|
() => selectedIdx.value,
|
||||||
() => {
|
() => {
|
||||||
|
if (props.value) {
|
||||||
|
emit("update:modelValue", props.items[selectedIdx.value][props.value]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
emit("update:modelValue", props.items[selectedIdx.value]);
|
emit("update:modelValue", props.items[selectedIdx.value]);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
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,6 +1,13 @@
|
||||||
import { BaseAPI, route } from "../base";
|
import { BaseAPI, route } from "../base";
|
||||||
import { parseDate } from "../base/base-api";
|
import { parseDate } from "../base/base-api";
|
||||||
import { ItemAttachmentToken, 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 { AttachmentTypes } from "../types/non-generated";
|
||||||
import { Results } from "./types";
|
import { Results } from "./types";
|
||||||
|
|
||||||
|
@ -76,7 +83,14 @@ export class ItemsApi extends BaseAPI {
|
||||||
return route(`/items/${id}/attachments/download`, { token: payload.data.token });
|
return route(`/items/${id}/attachments/download`, { token: payload.data.token });
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteAttachment(id: string, attachmentId: string) {
|
async deleteAttachment(id: string, attachmentId: string) {
|
||||||
return this.http.delete<void>({ url: route(`/items/${id}/attachments/${attachmentId}`) });
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -53,6 +53,11 @@ export interface ItemAttachmentToken {
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ItemAttachmentUpdate {
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ItemCreate {
|
export interface ItemCreate {
|
||||||
description: string;
|
description: string;
|
||||||
labelIds: string[];
|
labelIds: string[];
|
||||||
|
|
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",
|
||||||
|
}
|
|
@ -121,7 +121,7 @@
|
||||||
<div class="sm:flex sm:space-x-5">
|
<div class="sm:flex sm:space-x-5">
|
||||||
<div class="mt-4 text-center sm:mt-0 sm:pt-1 sm:text-left">
|
<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-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>
|
<p class="text-sm font-medium text-gray-600">User</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
<script setup lang="ts">
|
<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 { useLabelStore } from "~~/stores/labels";
|
||||||
import { useLocationStore } from "~~/stores/locations";
|
import { useLocationStore } from "~~/stores/locations";
|
||||||
|
import { capitalize } from "~~/lib/strings";
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "home",
|
layout: "home",
|
||||||
|
@ -154,10 +156,131 @@
|
||||||
ref: "soldTime",
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseContainer v-if="item" class="pb-8">
|
<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">
|
<section class="px-3">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="overflow-hidden card bg-base-100 shadow-xl sm:rounded-lg">
|
<div class="overflow-hidden card bg-base-100 shadow-xl sm:rounded-lg">
|
||||||
|
@ -223,6 +346,62 @@
|
||||||
</div>
|
</div>
|
||||||
</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 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">
|
<div class="px-4 py-5 sm:px-6">
|
||||||
<h3 class="text-lg font-medium leading-6">Purchase Details</h3>
|
<h3 class="text-lg font-medium leading-6">Purchase Details</h3>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue