mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-21 13:23:14 +01:00
* fix: add focus-triggered preloading to ItemSelector with proper error handling and complete localization * Removed machine translated files --------- Co-authored-by: Tonya <tonya@tokia.dev>
907 lines
29 KiB
Vue
907 lines
29 KiB
Vue
<!-- eslint-disable @typescript-eslint/no-explicit-any -->
|
|
<script setup lang="ts">
|
|
import { useI18n } from "vue-i18n";
|
|
import { toast } from "@/components/ui/sonner";
|
|
import type { ItemAttachment, ItemField, ItemOut, 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 MdiLoading from "~icons/mdi/loading";
|
|
import MdiDelete from "~icons/mdi/delete";
|
|
import MdiPencil from "~icons/mdi/pencil";
|
|
import MdiContentSaveOutline from "~icons/mdi/content-save-outline";
|
|
import MdiImageOutline from "~icons/mdi/image-outline";
|
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useDialog } from "@/components/ui/dialog-provider";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Label } from "@/components/ui/label";
|
|
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
|
import FormTextField from "~/components/Form/TextField.vue";
|
|
import FormTextArea from "~/components/Form/TextArea.vue";
|
|
import FormDatePicker from "~/components/Form/DatePicker.vue";
|
|
import FormCheckbox from "~/components/Form/Checkbox.vue";
|
|
import LocationSelector from "~/components/Location/Selector.vue";
|
|
import ItemSelector from "~/components/Item/Selector.vue";
|
|
import LabelSelector from "~/components/Label/Selector.vue";
|
|
import BaseCard from "@/components/Base/Card.vue";
|
|
import { Card } from "~/components/ui/card";
|
|
import DropZone from "~/components/global/DropZone.vue";
|
|
|
|
const { t } = useI18n();
|
|
|
|
const { openDialog, closeDialog } = useDialog();
|
|
|
|
definePageMeta({
|
|
middleware: ["auth"],
|
|
});
|
|
|
|
const route = useRoute();
|
|
const api = useUserApi();
|
|
const preferences = useViewPreferences();
|
|
|
|
const itemId = computed<string>(() => route.params.id as string);
|
|
|
|
const locationStore = useLocationStore();
|
|
const locations = computed(() => locationStore.allLocations);
|
|
|
|
const labelStore = useLabelStore();
|
|
const labels = computed(() => labelStore.labels);
|
|
|
|
const {
|
|
data: nullableItem,
|
|
refresh,
|
|
pending: requestPending,
|
|
} = useAsyncData(async () => {
|
|
const { data, error } = await api.items.get(itemId.value);
|
|
if (error) {
|
|
toast.error(t("items.toast.failed_load_item"));
|
|
navigateTo("/home");
|
|
return;
|
|
}
|
|
|
|
if (locations.value && data.location?.id) {
|
|
// @ts-expect-error - we know the locations is valid
|
|
const location = locations.value.find(l => l.id === data.location.id);
|
|
if (location) {
|
|
data.location = location;
|
|
}
|
|
}
|
|
|
|
if (data.parent) {
|
|
parent.value = data.parent;
|
|
}
|
|
|
|
return data;
|
|
});
|
|
|
|
const item = ref<ItemOut & { labelIds: string[] }>(null as never);
|
|
|
|
watchEffect(() => {
|
|
if (nullableItem.value) {
|
|
item.value = {
|
|
...nullableItem.value,
|
|
labelIds: nullableItem.value.labels.map(l => l.id) ?? [],
|
|
};
|
|
}
|
|
});
|
|
|
|
// const item = computed(() => nullableItem.value as ItemOut);
|
|
|
|
onMounted(() => {
|
|
refresh();
|
|
});
|
|
|
|
const saving = ref(false);
|
|
|
|
async function saveItem() {
|
|
if (!item.value.location?.id) {
|
|
toast.error(t("items.toast.failed_save_no_location"));
|
|
return;
|
|
}
|
|
|
|
saving.value = true;
|
|
|
|
let purchasePrice = 0;
|
|
let soldPrice = 0;
|
|
if (item.value.purchasePrice) {
|
|
purchasePrice = item.value.purchasePrice;
|
|
}
|
|
if (item.value.soldPrice) {
|
|
soldPrice = item.value.soldPrice;
|
|
}
|
|
|
|
console.log((item.value.purchasePrice ??= 0));
|
|
console.log((item.value.soldPrice ??= 0));
|
|
|
|
const payload: ItemUpdate = {
|
|
...item.value,
|
|
locationId: item.value.location?.id,
|
|
labelIds: item.value.labelIds,
|
|
parentId: parent.value ? parent.value.id : null,
|
|
assetId: item.value.assetId,
|
|
purchasePrice,
|
|
soldPrice,
|
|
purchaseTime: item.value.purchaseTime as Date,
|
|
};
|
|
|
|
const { error } = await api.items.update(itemId.value, payload);
|
|
|
|
saving.value = false;
|
|
|
|
if (error) {
|
|
toast.error(t("items.toast.failed_save"));
|
|
return;
|
|
}
|
|
|
|
toast.success(t("items.toast.item_saved"));
|
|
navigateTo("/item/" + itemId.value);
|
|
}
|
|
|
|
type NonNullableStringKeys<T> = Extract<keyof T, keyof { [K in keyof T as T[K] extends string ? K : never]: any }>;
|
|
type NonNullableNumberKeys<T> = Extract<keyof T, keyof { [K in keyof T as T[K] extends number ? K : never]: any }>;
|
|
type BooleanKeys<T> = Extract<keyof T, keyof { [K in keyof T as T[K] extends boolean ? K : never]: any }>;
|
|
type DateKeys<T> = Extract<keyof T, keyof { [K in keyof T as T[K] extends Date | string ? K : never]: any }>;
|
|
|
|
type TextFormField = {
|
|
type: "text" | "textarea";
|
|
label: string;
|
|
ref: NonNullableStringKeys<ItemOut>;
|
|
maxLength?: number;
|
|
minLength?: number;
|
|
};
|
|
|
|
type NumberFormField = {
|
|
type: "number";
|
|
label: string;
|
|
ref: NonNullableNumberKeys<ItemOut> | NonNullableStringKeys<ItemOut>;
|
|
};
|
|
|
|
interface BoolFormField {
|
|
type: "checkbox";
|
|
label: string;
|
|
ref: BooleanKeys<ItemOut>;
|
|
}
|
|
|
|
type DateFormField = {
|
|
type: "date";
|
|
label: string;
|
|
ref: DateKeys<ItemOut>;
|
|
};
|
|
|
|
type FormField = TextFormField | BoolFormField | DateFormField | NumberFormField;
|
|
|
|
const mainFields: FormField[] = [
|
|
{
|
|
type: "text",
|
|
label: "items.name",
|
|
ref: "name",
|
|
maxLength: 255,
|
|
minLength: 1,
|
|
},
|
|
{
|
|
type: "number",
|
|
label: "items.quantity",
|
|
ref: "quantity",
|
|
},
|
|
{
|
|
type: "textarea",
|
|
label: "items.description",
|
|
ref: "description",
|
|
maxLength: 1000,
|
|
},
|
|
{
|
|
type: "text",
|
|
label: "items.serial_number",
|
|
ref: "serialNumber",
|
|
maxLength: 255,
|
|
},
|
|
{
|
|
type: "text",
|
|
label: "items.model_number",
|
|
ref: "modelNumber",
|
|
maxLength: 255,
|
|
},
|
|
{
|
|
type: "text",
|
|
label: "items.manufacturer",
|
|
ref: "manufacturer",
|
|
maxLength: 255,
|
|
},
|
|
{
|
|
type: "textarea",
|
|
label: "items.notes",
|
|
ref: "notes",
|
|
maxLength: 1000,
|
|
},
|
|
{
|
|
type: "checkbox",
|
|
label: "items.insured",
|
|
ref: "insured",
|
|
},
|
|
{
|
|
type: "checkbox",
|
|
label: "items.archived",
|
|
ref: "archived",
|
|
},
|
|
{
|
|
type: "text",
|
|
label: "items.asset_id",
|
|
ref: "assetId",
|
|
},
|
|
];
|
|
|
|
const purchaseFields: FormField[] = [
|
|
{
|
|
type: "text",
|
|
label: "items.purchased_from",
|
|
ref: "purchaseFrom",
|
|
maxLength: 255,
|
|
},
|
|
{
|
|
type: "number",
|
|
label: "items.purchase_price",
|
|
ref: "purchasePrice",
|
|
},
|
|
{
|
|
type: "date",
|
|
label: "items.purchase_date",
|
|
ref: "purchaseTime",
|
|
},
|
|
];
|
|
|
|
const warrantyFields: FormField[] = [
|
|
{
|
|
type: "checkbox",
|
|
label: "items.lifetime_warranty",
|
|
ref: "lifetimeWarranty",
|
|
},
|
|
{
|
|
type: "date",
|
|
label: "items.warranty_expires",
|
|
ref: "warrantyExpires",
|
|
},
|
|
{
|
|
type: "textarea",
|
|
label: "items.warranty_details",
|
|
ref: "warrantyDetails",
|
|
maxLength: 1000,
|
|
},
|
|
];
|
|
|
|
const soldFields: FormField[] = [
|
|
{
|
|
type: "text",
|
|
label: "items.sold_to",
|
|
ref: "soldTo",
|
|
maxLength: 255,
|
|
},
|
|
{
|
|
type: "number",
|
|
label: "items.sold_price",
|
|
ref: "soldPrice",
|
|
},
|
|
{
|
|
type: "date",
|
|
label: "items.sold_at",
|
|
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: Event) {
|
|
const files = (e.target as HTMLInputElement).files;
|
|
if (!files || !files.item(0)) {
|
|
return;
|
|
}
|
|
|
|
const first = files.item(0);
|
|
if (!first) {
|
|
return;
|
|
}
|
|
|
|
uploadAttachment([first], null);
|
|
}
|
|
|
|
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);
|
|
const dropReceipt = (files: File[] | null) => uploadAttachment(files, AttachmentTypes.Receipt);
|
|
|
|
async function uploadAttachment(files: File[] | null, type: AttachmentTypes | null) {
|
|
if (!files || files.length === 0 || !files[0]) {
|
|
return;
|
|
}
|
|
|
|
const { data, error } = await api.items.attachments.add(itemId.value, files[0], files[0].name, type);
|
|
|
|
if (error) {
|
|
toast.error(t("items.toast.failed_upload_attachment"));
|
|
return;
|
|
}
|
|
|
|
toast.success(t("items.toast.attachment_uploaded"));
|
|
|
|
item.value.attachments = data.attachments;
|
|
}
|
|
|
|
const confirm = useConfirm();
|
|
|
|
async function deleteAttachment(attachmentId: string) {
|
|
const confirmed = await confirm.open(t("items.delete_attachment_confirm"));
|
|
|
|
if (confirmed.isCanceled) {
|
|
return;
|
|
}
|
|
|
|
const { error } = await api.items.attachments.delete(itemId.value, attachmentId);
|
|
|
|
if (error) {
|
|
toast.error(t("items.toast.failed_delete_attachment"));
|
|
return;
|
|
}
|
|
|
|
toast.success(t("items.toast.attachment_deleted"));
|
|
item.value.attachments = item.value.attachments.filter(a => a.id !== attachmentId);
|
|
}
|
|
|
|
const editState = reactive({
|
|
loading: false,
|
|
|
|
// Values
|
|
obj: {},
|
|
id: "",
|
|
title: "",
|
|
type: "",
|
|
primary: false,
|
|
});
|
|
|
|
const attachmentOpts = Object.entries(AttachmentTypes).map(([key, value]) => ({
|
|
text: key[0]!.toUpperCase() + key.slice(1),
|
|
value,
|
|
}));
|
|
|
|
function openAttachmentEditDialog(attachment: ItemAttachment) {
|
|
editState.id = attachment.id;
|
|
editState.title = attachment.title;
|
|
editState.type = attachment.type;
|
|
editState.primary = attachment.primary;
|
|
openDialog(DialogID.AttachmentEdit);
|
|
|
|
editState.obj = attachmentOpts.find(o => o.value === attachment.type) || attachmentOpts[0]!;
|
|
}
|
|
|
|
async function updateAttachment() {
|
|
editState.loading = true;
|
|
const { error, data } = await api.items.attachments.update(itemId.value, editState.id, {
|
|
title: editState.title,
|
|
type: editState.type,
|
|
primary: editState.primary,
|
|
});
|
|
|
|
if (error) {
|
|
toast.error(t("items.toast.failed_delete_attachment"));
|
|
return;
|
|
}
|
|
|
|
item.value.attachments = data.attachments;
|
|
|
|
editState.loading = false;
|
|
closeDialog(DialogID.AttachmentEdit);
|
|
|
|
editState.id = "";
|
|
editState.title = "";
|
|
editState.type = "";
|
|
|
|
toast.success(t("items.toast.attachment_updated"));
|
|
}
|
|
|
|
function addField() {
|
|
item.value.fields.push({
|
|
id: null,
|
|
name: "Field Name",
|
|
type: "text",
|
|
textValue: "",
|
|
numberValue: 0,
|
|
booleanValue: false,
|
|
timeValue: null,
|
|
} as unknown as ItemField);
|
|
}
|
|
|
|
const { query, results, isLoading, triggerSearch } = useItemSearch(api, { immediate: false });
|
|
const parent = ref();
|
|
|
|
async function keyboardSave(e: KeyboardEvent) {
|
|
// Cmd + S
|
|
if (e.metaKey && e.key === "s") {
|
|
e.preventDefault();
|
|
await saveItem();
|
|
}
|
|
|
|
// Ctrl + S
|
|
if (e.ctrlKey && e.key === "s") {
|
|
e.preventDefault();
|
|
await saveItem();
|
|
}
|
|
}
|
|
|
|
async function maybeSyncWithParentLocation() {
|
|
if (parent.value && parent.value.id) {
|
|
const { data, error } = await api.items.get(parent.value.id);
|
|
|
|
if (error) {
|
|
toast.error(t("items.toast.error_loading_parent_data"));
|
|
return;
|
|
}
|
|
|
|
if (data.syncChildItemsLocations) {
|
|
toast.info(t("items.toast.sync_child_location"));
|
|
item.value.location = data.location;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function informAboutDesyncingLocationFromParent() {
|
|
if (parent.value && parent.value.id) {
|
|
const { data, error } = await api.items.get(parent.value.id);
|
|
|
|
if (error) {
|
|
toast.error(t("items.toast.error_loading_parent_data"));
|
|
return;
|
|
}
|
|
|
|
if (data.syncChildItemsLocations) {
|
|
toast.info(t("items.toast.child_location_desync"));
|
|
}
|
|
}
|
|
}
|
|
|
|
async function syncChildItemsLocations() {
|
|
if (!item.value.location?.id) {
|
|
toast.error(t("items.toast.failed_save_no_location"));
|
|
return;
|
|
}
|
|
|
|
const payload: ItemUpdate = {
|
|
...item.value,
|
|
locationId: item.value.location?.id,
|
|
labelIds: item.value.labelIds,
|
|
parentId: parent.value ? parent.value.id : null,
|
|
assetId: item.value.assetId,
|
|
};
|
|
|
|
const { error } = await api.items.update(itemId.value, payload);
|
|
|
|
if (error) {
|
|
toast.error("Failed to save item");
|
|
return;
|
|
}
|
|
|
|
if (!item.value.syncChildItemsLocations) {
|
|
toast.success(t("items.toast.child_items_location_no_longer_synced"));
|
|
} else {
|
|
toast.success(t("items.toast.child_items_location_synced"));
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
window.addEventListener("keydown", keyboardSave);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener("keydown", keyboardSave);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="item" class="pb-8">
|
|
<Dialog :dialog-id="DialogID.AttachmentEdit">
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{{ $t("items.edit.edit_attachment_dialog.title") }}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<FormTextField v-model="editState.title" :label="$t('items.edit.edit_attachment_dialog.attachment_title')" />
|
|
<div>
|
|
<Label for="attachment-type"> {{ $t("items.edit.edit_attachment_dialog.attachment_type") }} </Label>
|
|
<Select id="attachment-type" v-model:model-value="editState.type">
|
|
<SelectTrigger>
|
|
<SelectValue :placeholder="$t('items.edit.edit_attachment_dialog.select_type')" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="opt in attachmentOpts" :key="opt.value" :value="opt.value">
|
|
{{ opt.text }}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div v-if="editState.type == 'photo'" class="mt-3 flex items-center gap-2">
|
|
<Checkbox
|
|
id="primary"
|
|
v-model="editState.primary"
|
|
:label="$t('items.edit.edit_attachment_dialog.primary_photo')"
|
|
/>
|
|
<label class="cursor-pointer text-sm" for="primary">
|
|
<span class="font-semibold">{{ $t("items.edit.edit_attachment_dialog.primary_photo") }}</span>
|
|
{{ $t("items.edit.edit_attachment_dialog.primary_photo_sub") }}
|
|
</label>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button :disabled="editState.loading" @click="updateAttachment">
|
|
<MdiLoading v-if="editState.loading" class="animate-spin" />
|
|
{{ $t("global.update") }}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<section class="relative">
|
|
<div
|
|
class="sticky z-10 my-4 flex items-center justify-between gap-2"
|
|
:class="{
|
|
'top-[calc(var(--header-height-mobile)+0.25rem)] sm:top-[calc(var(--header-height)+0.25rem)]':
|
|
!preferences.displayLegacyHeader,
|
|
'top-1': preferences.displayLegacyHeader,
|
|
}"
|
|
>
|
|
<TooltipProvider :delay-duration="0">
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<Label class="flex cursor-pointer items-center gap-2 backdrop-blur-sm">
|
|
<Switch v-model="preferences.editorAdvancedView" />
|
|
{{ $t("items.advanced") }}
|
|
</Label>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ $t("items.show_advanced_view_options") }}</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
<Button size="sm" :disabled="saving" @click="saveItem">
|
|
<MdiLoading v-if="saving" class="animate-spin" />
|
|
<MdiContentSaveOutline v-else />
|
|
{{ $t("global.save") }}
|
|
</Button>
|
|
</div>
|
|
<div v-if="!requestPending" class="space-y-6">
|
|
<BaseCard class="overflow-visible">
|
|
<template #title> {{ $t("items.edit_details") }} </template>
|
|
<div class="mb-6 grid gap-4 border-t px-5 pt-2 md:grid-cols-2">
|
|
<LocationSelector v-model="item.location" @update:model-value="informAboutDesyncingLocationFromParent()" />
|
|
<ItemSelector
|
|
v-model="parent"
|
|
v-model:search="query"
|
|
:items="results"
|
|
item-text="name"
|
|
:label="$t('items.parent_item')"
|
|
no-results-text="Type to search..."
|
|
:exclude-items="[item]"
|
|
:is-loading="isLoading"
|
|
:trigger-search="triggerSearch"
|
|
@update:model-value="maybeSyncWithParentLocation()"
|
|
/>
|
|
<div class="flex flex-col gap-2">
|
|
<Label class="px-1">{{ $t("items.sync_child_locations") }}</Label>
|
|
<Switch v-model="item.syncChildItemsLocations" @update:model-value="syncChildItemsLocations()" />
|
|
</div>
|
|
<LabelSelector v-model="item.labelIds" :labels="labels" />
|
|
</div>
|
|
|
|
<div class="border-t sm:p-0">
|
|
<div v-for="field in mainFields" :key="field.ref" class="grid grid-cols-1 sm:divide-y">
|
|
<div class="border-b px-4 pb-4 pt-2 sm:px-6">
|
|
<FormTextArea
|
|
v-if="field.type === 'textarea'"
|
|
v-model="item[field.ref]"
|
|
:label="$t(field.label)"
|
|
inline
|
|
:max-length="field.maxLength"
|
|
:min-length="field.minLength"
|
|
/>
|
|
<FormTextField
|
|
v-else-if="field.type === 'text'"
|
|
v-model="item[field.ref]"
|
|
:label="$t(field.label)"
|
|
inline
|
|
type="text"
|
|
:max-length="field.maxLength"
|
|
:min-length="field.minLength"
|
|
/>
|
|
<FormTextField
|
|
v-else-if="field.type === 'number'"
|
|
v-model.number="item[field.ref]"
|
|
type="number"
|
|
:label="$t(field.label)"
|
|
inline
|
|
/>
|
|
<FormDatePicker
|
|
v-else-if="field.type === 'date'"
|
|
v-model="item[field.ref]"
|
|
:label="$t(field.label)"
|
|
inline
|
|
/>
|
|
<FormCheckbox
|
|
v-else-if="field.type === 'checkbox'"
|
|
v-model="item[field.ref]"
|
|
:label="$t(field.label)"
|
|
inline
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</BaseCard>
|
|
|
|
<BaseCard v-if="preferences.editorAdvancedView">
|
|
<template #title> {{ $t("items.custom_fields") }} </template>
|
|
<div class="space-y-4 divide-y border-t px-5">
|
|
<div
|
|
v-for="(field, idx) in item.fields"
|
|
:key="`field-${idx}`"
|
|
class="grid grid-cols-2 gap-2 pt-4 md:grid-cols-4"
|
|
>
|
|
<!-- <FormSelect v-model:value="field.type" label="Field Type" :items="fieldTypes" value-key="value" /> -->
|
|
<FormTextField v-model="field.name" :label="$t('global.name')" />
|
|
<div class="col-span-3 flex items-end">
|
|
<FormTextField v-model="field.textValue" :label="$t('global.value')" :max-length="500" />
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<Button size="icon" variant="destructive" class="ml-2" @click="item.fields.splice(idx, 1)">
|
|
<MdiDelete />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ $t("global.delete") }}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mt-4 flex justify-end px-5 pb-4">
|
|
<Button size="sm" @click="addField"> {{ $t("global.add") }} </Button>
|
|
</div>
|
|
</BaseCard>
|
|
|
|
<Card ref="attDropZone" class="overflow-visible shadow-xl">
|
|
<div class="px-4 py-5 sm:px-6">
|
|
<h3 class="text-lg font-medium leading-6">{{ $t("items.attachments") }}</h3>
|
|
<p class="text-xs">{{ $t("items.changes_persisted_immediately") }}</p>
|
|
</div>
|
|
<div class="border-t p-4">
|
|
<div v-if="attDropZoneActive" class="grid grid-cols-4 gap-4">
|
|
<DropZone @drop="dropPhoto"> {{ $t("items.photos") }} </DropZone>
|
|
<DropZone @drop="dropWarranty"> {{ $t("items.warranty") }} </DropZone>
|
|
<DropZone @drop="dropManual"> {{ $t("items.manuals") }} </DropZone>
|
|
<DropZone @drop="dropAttachment"> {{ $t("items.attachments") }} </DropZone>
|
|
<DropZone @drop="dropReceipt"> {{ $t("items.receipts") }} </DropZone>
|
|
</div>
|
|
<button
|
|
v-else
|
|
class="grid h-24 w-full place-content-center border-2 border-dashed border-primary"
|
|
@click="clickUpload"
|
|
>
|
|
<input ref="refAttachmentInput" hidden type="file" @change="uploadImage" />
|
|
<p>{{ $t("items.drag_and_drop") }}</p>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="border-t p-4">
|
|
<ul role="list" class="divide-y rounded-md border">
|
|
<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="col-span-4 my-auto">
|
|
{{ attachment.title }}
|
|
</p>
|
|
<p class="my-auto">
|
|
{{ $t(`items.${attachment.type}`) }}
|
|
</p>
|
|
<div class="flex justify-end gap-2">
|
|
<Tooltip v-if="attachment.type === 'photo'">
|
|
<TooltipTrigger as-child>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
@click="
|
|
openDialog(DialogID.ItemImage, {
|
|
params: {
|
|
type: 'attachment',
|
|
itemId: item.id,
|
|
attachmentId: attachment.id,
|
|
thumbnailId: attachment.thumbnail?.id,
|
|
mimeType: attachment.mimeType,
|
|
},
|
|
onClose: result => {
|
|
if (result?.action === 'delete') {
|
|
item.attachments = item.attachments.filter(a => a.id !== result.id);
|
|
}
|
|
},
|
|
})
|
|
"
|
|
>
|
|
<MdiImageOutline />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ $t("items.edit.view_image") }}</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<Button variant="destructive" size="icon" @click="deleteAttachment(attachment.id)">
|
|
<MdiDelete />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ $t("global.delete") }}</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<Button size="icon" @click="openAttachmentEditDialog(attachment)">
|
|
<MdiPencil />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{{ $t("global.edit") }}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card v-if="preferences.editorAdvancedView" class="overflow-visible shadow-xl">
|
|
<div class="px-4 py-5 sm:px-6">
|
|
<h3 class="text-lg font-medium leading-6">{{ $t("items.purchase_details") }}</h3>
|
|
</div>
|
|
<div class="border-t sm:p-0">
|
|
<div v-for="field in purchaseFields" :key="field.ref" class="grid grid-cols-1 sm:divide-y">
|
|
<div class="border-b px-4 pb-4 pt-2 sm:px-6">
|
|
<FormTextArea
|
|
v-if="field.type === 'textarea'"
|
|
v-model="item[field.ref]"
|
|
:label="$t(field.label)"
|
|
inline
|
|
:max-length="field.maxLength"
|
|
:min-length="field.minLength"
|
|
/>
|
|
<FormTextField
|
|
v-else-if="field.type === 'text'"
|
|
v-model="item[field.ref]"
|
|
:label="$t(field.label)"
|
|
inline
|
|
:max-length="field.maxLength"
|
|
:min-length="field.minLength"
|
|
/>
|
|
<FormTextField
|
|
v-else-if="field.type === 'number'"
|
|
v-model.number="item[field.ref]"
|
|
type="number"
|
|
:label="$t(field.label)"
|
|
inline
|
|
/>
|
|
<FormDatePicker
|
|
v-else-if="field.type === 'date'"
|
|
v-model="item[field.ref]"
|
|
:label="$t(field.label)"
|
|
inline
|
|
/>
|
|
<FormCheckbox
|
|
v-else-if="field.type === 'checkbox'"
|
|
v-model="item[field.ref]"
|
|
:label="$t(field.label)"
|
|
inline
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card v-if="preferences.editorAdvancedView" class="overflow-visible shadow-xl">
|
|
<div class="px-4 py-5 sm:px-6">
|
|
<h3 class="text-lg font-medium leading-6">{{ $t("items.warranty_details") }}</h3>
|
|
</div>
|
|
<div class="border-t sm:p-0">
|
|
<div v-for="field in warrantyFields" :key="field.ref" class="grid grid-cols-1 sm:divide-y">
|
|
<div class="border-b px-4 pb-4 pt-2 sm:px-6">
|
|
<FormTextArea
|
|
v-if="field.type === 'textarea'"
|
|
v-model="item[field.ref]"
|
|
:label="$t(field.label)"
|
|
inline
|
|
:max-length="field.maxLength"
|
|
:min-length="field.minLength"
|
|
/>
|
|
<FormTextField
|
|
v-else-if="field.type === 'text'"
|
|
v-model="item[field.ref]"
|
|
:label="$t(field.label)"
|
|
inline
|
|
:max-length="field.maxLength"
|
|
:min-length="field.minLength"
|
|
/>
|
|
<FormTextField
|
|
v-else-if="field.type === 'number'"
|
|
v-model.number="item[field.ref]"
|
|
type="number"
|
|
:label="$t(field.label)"
|
|
inline
|
|
/>
|
|
<FormDatePicker
|
|
v-else-if="field.type === 'date'"
|
|
v-model="item[field.ref]"
|
|
:label="$t(field.label)"
|
|
inline
|
|
/>
|
|
<FormCheckbox
|
|
v-else-if="field.type === 'checkbox'"
|
|
v-model="item[field.ref]"
|
|
:label="$t(field.label)"
|
|
inline
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card v-if="preferences.editorAdvancedView" class="overflow-visible shadow-xl">
|
|
<div class="px-4 py-5 sm:px-6">
|
|
<h3 class="text-lg font-medium leading-6">{{ $t("items.sold_details") }}</h3>
|
|
</div>
|
|
<div class="border-t sm:p-0">
|
|
<div v-for="field in soldFields" :key="field.ref" class="grid grid-cols-1 sm:divide-y">
|
|
<div class="border-b px-4 pb-4 pt-2 sm:px-6">
|
|
<FormTextArea
|
|
v-if="field.type === 'textarea'"
|
|
v-model="item[field.ref]"
|
|
:label="$t(field.label)"
|
|
inline
|
|
:max-length="field.maxLength"
|
|
:min-length="field.minLength"
|
|
/>
|
|
<FormTextField
|
|
v-else-if="field.type === 'text'"
|
|
v-model="item[field.ref]"
|
|
:label="$t(field.label)"
|
|
inline
|
|
:max-length="field.maxLength"
|
|
:min-length="field.minLength"
|
|
/>
|
|
<FormTextField
|
|
v-else-if="field.type === 'number'"
|
|
v-model.number="item[field.ref]"
|
|
type="number"
|
|
:label="$t(field.label)"
|
|
inline
|
|
/>
|
|
<FormDatePicker
|
|
v-else-if="field.type === 'date'"
|
|
v-model="item[field.ref]"
|
|
:label="$t(field.label)"
|
|
inline
|
|
/>
|
|
<FormCheckbox
|
|
v-else-if="field.type === 'checkbox'"
|
|
v-model="item[field.ref]"
|
|
:label="$t(field.label)"
|
|
inline
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|