feat: improved duplicate (#927)

* feat: improved duplicate

* feat: enhance item duplication process with transaction handling and error logging for attachments and fields

* feat: add error logging during transaction rollback in item duplication process for better debugging

* feat: don't try and rollback is the commit succeeded

* feat: add customizable duplication options for items, including prefix and field copying settings in API and UI

* fix: simplify duplication checks for custom fields, attachments, and maintenance entries in ItemsRepository duplication method

* refactor: import DuplicateSettings type from composables and sort import issues
This commit is contained in:
Tonya
2025-08-23 16:17:15 +01:00
committed by GitHub
parent 8b711eda99
commit 788d0b1c7e
18 changed files with 643 additions and 29 deletions

View File

@@ -254,6 +254,25 @@ func (ctrl *V1Controller) HandleItemPatch() errchain.HandlerFunc {
return adapters.ActionID("id", fn, http.StatusOK) return adapters.ActionID("id", fn, http.StatusOK)
} }
// HandleItemDuplicate godocs
//
// @Summary Duplicate Item
// @Tags Items
// @Produce json
// @Param id path string true "Item ID"
// @Param payload body repo.DuplicateOptions true "Duplicate Options"
// @Success 201 {object} repo.ItemOut
// @Router /v1/items/{id}/duplicate [POST]
// @Security Bearer
func (ctrl *V1Controller) HandleItemDuplicate() errchain.HandlerFunc {
fn := func(r *http.Request, ID uuid.UUID, options repo.DuplicateOptions) (repo.ItemOut, error) {
ctx := services.NewContext(r.Context())
return ctrl.svc.Items.Duplicate(ctx, ctx.GID, ID, options)
}
return adapters.ActionID("id", fn, http.StatusCreated)
}
// HandleGetAllCustomFieldNames godocs // HandleGetAllCustomFieldNames godocs
// //
// @Summary Get All Custom Field Names // @Summary Get All Custom Field Names

View File

@@ -129,6 +129,7 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR
r.Put("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemUpdate(), userMW...)) r.Put("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemUpdate(), userMW...))
r.Patch("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemPatch(), userMW...)) r.Patch("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemPatch(), userMW...))
r.Delete("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemDelete(), userMW...)) r.Delete("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemDelete(), userMW...))
r.Post("/items/{id}/duplicate", chain.ToHandlerFunc(v1Ctrl.HandleItemDuplicate(), userMW...))
r.Post("/items/{id}/attachments", chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentCreate(), userMW...)) r.Post("/items/{id}/attachments", chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentCreate(), userMW...))
r.Put("/items/{id}/attachments/{attachment_id}", chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentUpdate(), userMW...)) r.Put("/items/{id}/attachments/{attachment_id}", chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentUpdate(), userMW...))

View File

@@ -943,6 +943,48 @@ const docTemplate = `{
} }
} }
}, },
"/v1/items/{id}/duplicate": {
"post": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Items"
],
"summary": "Duplicate Item",
"parameters": [
{
"type": "string",
"description": "Item ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Duplicate Options",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/repo.DuplicateOptions"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/repo.ItemOut"
}
}
}
}
},
"/v1/items/{id}/maintenance": { "/v1/items/{id}/maintenance": {
"get": { "get": {
"security": [ "security": [
@@ -3129,6 +3171,23 @@ const docTemplate = `{
} }
} }
}, },
"repo.DuplicateOptions": {
"type": "object",
"properties": {
"copyAttachments": {
"type": "boolean"
},
"copyCustomFields": {
"type": "boolean"
},
"copyMaintenance": {
"type": "boolean"
},
"copyPrefix": {
"type": "string"
}
}
},
"repo.Group": { "repo.Group": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -941,6 +941,48 @@
} }
} }
}, },
"/v1/items/{id}/duplicate": {
"post": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Items"
],
"summary": "Duplicate Item",
"parameters": [
{
"type": "string",
"description": "Item ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Duplicate Options",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/repo.DuplicateOptions"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/repo.ItemOut"
}
}
}
}
},
"/v1/items/{id}/maintenance": { "/v1/items/{id}/maintenance": {
"get": { "get": {
"security": [ "security": [
@@ -3127,6 +3169,23 @@
} }
} }
}, },
"repo.DuplicateOptions": {
"type": "object",
"properties": {
"copyAttachments": {
"type": "boolean"
},
"copyCustomFields": {
"type": "boolean"
},
"copyMaintenance": {
"type": "boolean"
},
"copyPrefix": {
"type": "string"
}
}
},
"repo.Group": { "repo.Group": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -667,6 +667,17 @@ definitions:
search_engine_name: search_engine_name:
type: string type: string
type: object type: object
repo.DuplicateOptions:
properties:
copyAttachments:
type: boolean
copyCustomFields:
type: boolean
copyMaintenance:
type: boolean
copyPrefix:
type: string
type: object
repo.Group: repo.Group:
properties: properties:
createdAt: createdAt:
@@ -1968,6 +1979,32 @@ paths:
summary: Update Item Attachment summary: Update Item Attachment
tags: tags:
- Items Attachments - Items Attachments
/v1/items/{id}/duplicate:
post:
parameters:
- description: Item ID
in: path
name: id
required: true
type: string
- description: Duplicate Options
in: body
name: payload
required: true
schema:
$ref: '#/definitions/repo.DuplicateOptions'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/repo.ItemOut'
security:
- Bearer: []
summary: Duplicate Item
tags:
- Items
/v1/items/{id}/maintenance: /v1/items/{id}/maintenance:
get: get:
parameters: parameters:

View File

@@ -337,6 +337,7 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/olahol/melody v1.3.0 h1:n7UlKiQnxVrgxKoM0d7usZiN+Z0y2lVENtYLgKtXS6s= github.com/olahol/melody v1.3.0 h1:n7UlKiQnxVrgxKoM0d7usZiN+Z0y2lVENtYLgKtXS6s=
github.com/olahol/melody v1.3.0/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4= github.com/olahol/melody v1.3.0/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4=
github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU= github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=

View File

@@ -38,6 +38,10 @@ func (svc *ItemService) Create(ctx Context, item repo.ItemCreate) (repo.ItemOut,
return svc.repo.Items.Create(ctx, ctx.GID, item) return svc.repo.Items.Create(ctx, ctx.GID, item)
} }
func (svc *ItemService) Duplicate(ctx Context, gid, id uuid.UUID, options repo.DuplicateOptions) (repo.ItemOut, error) {
return svc.repo.Items.Duplicate(ctx, gid, id, options)
}
func (svc *ItemService) EnsureAssetID(ctx context.Context, gid uuid.UUID) (int, error) { func (svc *ItemService) EnsureAssetID(ctx context.Context, gid uuid.UUID) (int, error) {
items, err := svc.repo.Items.GetAllZeroAssetID(ctx, gid) items, err := svc.repo.Items.GetAllZeroAssetID(ctx, gid)
if err != nil { if err != nil {

View File

@@ -6,6 +6,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/sysadminsmedia/homebox/backend/internal/core/services/reporting/eventbus" "github.com/sysadminsmedia/homebox/backend/internal/core/services/reporting/eventbus"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent" "github.com/sysadminsmedia/homebox/backend/internal/data/ent"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/attachment" "github.com/sysadminsmedia/homebox/backend/internal/data/ent/attachment"
@@ -14,6 +15,7 @@ import (
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/itemfield" "github.com/sysadminsmedia/homebox/backend/internal/data/ent/itemfield"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/label" "github.com/sysadminsmedia/homebox/backend/internal/data/ent/label"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/location" "github.com/sysadminsmedia/homebox/backend/internal/data/ent/location"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/maintenanceentry"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/predicate" "github.com/sysadminsmedia/homebox/backend/internal/data/ent/predicate"
"github.com/sysadminsmedia/homebox/backend/internal/data/types" "github.com/sysadminsmedia/homebox/backend/internal/data/types"
) )
@@ -46,6 +48,13 @@ type (
OrderBy string `json:"orderBy"` OrderBy string `json:"orderBy"`
} }
DuplicateOptions struct {
CopyMaintenance bool `json:"copyMaintenance"`
CopyAttachments bool `json:"copyAttachments"`
CopyCustomFields bool `json:"copyCustomFields"`
CopyPrefix string `json:"copyPrefix"`
}
ItemField struct { ItemField struct {
ID uuid.UUID `json:"id,omitempty"` ID uuid.UUID `json:"id,omitempty"`
Type string `json:"type"` Type string `json:"type"`
@@ -1004,3 +1013,164 @@ func (e *ItemsRepository) SetPrimaryPhotos(ctx context.Context, gid uuid.UUID) (
return updated, nil return updated, nil
} }
// Duplicate creates a copy of an item with configurable options for what data to copy.
// The new item will have the next available asset ID and a customizable prefix in the name.
func (e *ItemsRepository) Duplicate(ctx context.Context, gid, id uuid.UUID, options DuplicateOptions) (ItemOut, error) {
tx, err := e.db.Tx(ctx)
if err != nil {
return ItemOut{}, err
}
committed := false
defer func() {
if !committed {
if err := tx.Rollback(); err != nil {
log.Warn().Err(err).Msg("failed to rollback transaction during item duplication")
}
}
}()
// Get the original item with all its data
originalItem, err := e.getOne(ctx, item.ID(id), item.HasGroupWith(group.ID(gid)))
if err != nil {
return ItemOut{}, err
}
nextAssetID, err := e.GetHighestAssetID(ctx, gid)
if err != nil {
return ItemOut{}, err
}
nextAssetID++
// Set default copy prefix if not provided
if options.CopyPrefix == "" {
options.CopyPrefix = "Copy of "
}
// Create the new item directly in the transaction
newItemID := uuid.New()
itemBuilder := tx.Item.Create().
SetID(newItemID).
SetName(options.CopyPrefix + originalItem.Name).
SetDescription(originalItem.Description).
SetQuantity(originalItem.Quantity).
SetLocationID(originalItem.Location.ID).
SetGroupID(gid).
SetAssetID(int(nextAssetID)).
SetSerialNumber(originalItem.SerialNumber).
SetModelNumber(originalItem.ModelNumber).
SetManufacturer(originalItem.Manufacturer).
SetLifetimeWarranty(originalItem.LifetimeWarranty).
SetWarrantyExpires(originalItem.WarrantyExpires.Time()).
SetWarrantyDetails(originalItem.WarrantyDetails).
SetPurchaseTime(originalItem.PurchaseTime.Time()).
SetPurchaseFrom(originalItem.PurchaseFrom).
SetPurchasePrice(originalItem.PurchasePrice).
SetSoldTime(originalItem.SoldTime.Time()).
SetSoldTo(originalItem.SoldTo).
SetSoldPrice(originalItem.SoldPrice).
SetSoldNotes(originalItem.SoldNotes).
SetNotes(originalItem.Notes).
SetInsured(originalItem.Insured).
SetArchived(originalItem.Archived).
SetSyncChildItemsLocations(originalItem.SyncChildItemsLocations)
if originalItem.Parent != nil {
itemBuilder.SetParentID(originalItem.Parent.ID)
}
// Add labels
if len(originalItem.Labels) > 0 {
labelIDs := make([]uuid.UUID, len(originalItem.Labels))
for i, label := range originalItem.Labels {
labelIDs[i] = label.ID
}
itemBuilder.AddLabelIDs(labelIDs...)
}
_, err = itemBuilder.Save(ctx)
if err != nil {
return ItemOut{}, err
}
// Copy custom fields if requested
if options.CopyCustomFields {
for _, field := range originalItem.Fields {
_, err = tx.ItemField.Create().
SetItemID(newItemID).
SetType(itemfield.Type(field.Type)).
SetName(field.Name).
SetTextValue(field.TextValue).
SetNumberValue(field.NumberValue).
SetBooleanValue(field.BooleanValue).
Save(ctx)
if err != nil {
log.Warn().Err(err).Str("field_name", field.Name).Msg("failed to copy custom field during duplication")
continue
}
}
}
// Copy attachments if requested
if options.CopyAttachments {
for _, att := range originalItem.Attachments {
// Get the original attachment file
originalAttachment, err := tx.Attachment.Query().
Where(attachment.ID(att.ID)).
Only(ctx)
if err != nil {
// Log error but continue to copy other attachments
log.Warn().Err(err).Str("attachment_id", att.ID.String()).Msg("failed to find attachment during duplication")
continue
}
// Create a copy of the attachment with the same file path
// Since files are stored with hash-based paths, this is safe
_, err = tx.Attachment.Create().
SetItemID(newItemID).
SetType(originalAttachment.Type).
SetTitle(originalAttachment.Title).
SetPath(originalAttachment.Path).
SetMimeType(originalAttachment.MimeType).
SetPrimary(originalAttachment.Primary).
Save(ctx)
if err != nil {
log.Warn().Err(err).Str("original_attachment_id", att.ID.String()).Msg("failed to copy attachment during duplication")
continue
}
}
}
// Copy maintenance entries if requested
if options.CopyMaintenance {
maintenanceEntries, err := tx.MaintenanceEntry.Query().
Where(maintenanceentry.HasItemWith(item.ID(id))).
All(ctx)
if err == nil {
for _, entry := range maintenanceEntries {
_, err = tx.MaintenanceEntry.Create().
SetItemID(newItemID).
SetDate(entry.Date).
SetScheduledDate(entry.ScheduledDate).
SetName(entry.Name).
SetDescription(entry.Description).
SetCost(entry.Cost).
Save(ctx)
if err != nil {
log.Warn().Err(err).Str("maintenance_entry_id", entry.ID.String()).Msg("failed to copy maintenance entry during duplication")
continue
}
}
}
}
if err := tx.Commit(); err != nil {
return ItemOut{}, err
}
committed = true
e.publishMutationEvent(gid)
// Get the final item with all copied data
return e.GetOne(ctx, newItemID)
}

View File

@@ -941,6 +941,48 @@
} }
} }
}, },
"/v1/items/{id}/duplicate": {
"post": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Items"
],
"summary": "Duplicate Item",
"parameters": [
{
"type": "string",
"description": "Item ID",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Duplicate Options",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/repo.DuplicateOptions"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/repo.ItemOut"
}
}
}
}
},
"/v1/items/{id}/maintenance": { "/v1/items/{id}/maintenance": {
"get": { "get": {
"security": [ "security": [
@@ -3127,6 +3169,23 @@
} }
} }
}, },
"repo.DuplicateOptions": {
"type": "object",
"properties": {
"copyAttachments": {
"type": "boolean"
},
"copyCustomFields": {
"type": "boolean"
},
"copyMaintenance": {
"type": "boolean"
},
"copyPrefix": {
"type": "string"
}
}
},
"repo.Group": { "repo.Group": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -667,6 +667,17 @@ definitions:
search_engine_name: search_engine_name:
type: string type: string
type: object type: object
repo.DuplicateOptions:
properties:
copyAttachments:
type: boolean
copyCustomFields:
type: boolean
copyMaintenance:
type: boolean
copyPrefix:
type: string
type: object
repo.Group: repo.Group:
properties: properties:
createdAt: createdAt:
@@ -1968,6 +1979,32 @@ paths:
summary: Update Item Attachment summary: Update Item Attachment
tags: tags:
- Items Attachments - Items Attachments
/v1/items/{id}/duplicate:
post:
parameters:
- description: Item ID
in: path
name: id
required: true
type: string
- description: Duplicate Options
in: body
name: payload
required: true
schema:
$ref: '#/definitions/repo.DuplicateOptions'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/repo.ItemOut'
security:
- Bearer: []
summary: Duplicate Item
tags:
- Items
/v1/items/{id}/maintenance: /v1/items/{id}/maintenance:
get: get:
parameters: parameters:

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import type { DuplicateSettings } from "~/composables/use-preferences";
type Props = {
modelValue: DuplicateSettings;
};
type Emits = {
(e: "update:modelValue", value: DuplicateSettings): void;
};
const props = defineProps<Props>();
const enableCustomPrefix = ref(props.modelValue.copyPrefixOverride !== null);
const prefix = ref(props.modelValue.copyPrefixOverride ?? "");
const emit = defineEmits<Emits>();
const settings = computed({
get: () => props.modelValue,
set: value => emit("update:modelValue", value),
});
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<Switch id="copy-maintenance" v-model="settings.copyMaintenance" />
<Label for="copy-maintenance">
{{ $t("items.duplicate.copy_maintenance") }}
</Label>
</div>
<div class="flex items-center gap-2">
<Switch id="copy-attachments" v-model="settings.copyAttachments" />
<Label for="copy-attachments">
{{ $t("items.duplicate.copy_attachments") }}
</Label>
</div>
<div class="flex items-center gap-2">
<Switch id="copy-custom-fields" v-model="settings.copyCustomFields" />
<Label for="copy-custom-fields">
{{ $t("items.duplicate.copy_custom_fields") }}
</Label>
</div>
<div class="flex items-center gap-2">
<Switch
id="copy-prefix"
v-model="enableCustomPrefix"
@update:model-value="
v => {
settings.copyPrefixOverride = v ? prefix : null;
}
"
/>
<Label for="copy-prefix">{{ $t("items.duplicate.enable_custom_prefix") }}</Label>
</div>
<div class="flex flex-col gap-2">
<Label for="copy-prefix" :class="{ 'opacity-50': !enableCustomPrefix }">
{{ $t("items.duplicate.custom_prefix") }}
</Label>
<Input
id="copy-prefix"
v-model="prefix"
:disabled="!enableCustomPrefix"
:placeholder="$t('items.duplicate.prefix')"
class="w-full"
@input="settings.copyPrefixOverride = prefix"
/>
<p class="text-sm text-muted-foreground">
{{ $t("items.duplicate.prefix_instructions") }}
</p>
</div>
</div>
</div>
</template>

View File

@@ -10,6 +10,8 @@ export enum DialogID {
CreateLocation = 'create-location', CreateLocation = 'create-location',
CreateLabel = 'create-label', CreateLabel = 'create-label',
CreateNotifier = 'create-notifier', CreateNotifier = 'create-notifier',
DuplicateSettings = 'duplicate-settings',
DuplicateTemporarySettings = 'duplicate-temporary-settings',
EditMaintenance = 'edit-maintenance', EditMaintenance = 'edit-maintenance',
Import = 'import', Import = 'import',
ItemImage = 'item-image', ItemImage = 'item-image',

View File

@@ -4,6 +4,13 @@ import type { DaisyTheme } from "~~/lib/data/themes";
export type ViewType = "table" | "card" | "tree"; export type ViewType = "table" | "card" | "tree";
export type DuplicateSettings = {
copyMaintenance: boolean;
copyAttachments: boolean;
copyCustomFields: boolean;
copyPrefixOverride: string | null;
};
export type LocationViewPreferences = { export type LocationViewPreferences = {
showDetails: boolean; showDetails: boolean;
showEmpty: boolean; showEmpty: boolean;
@@ -15,6 +22,7 @@ export type LocationViewPreferences = {
displayLegacyHeader: boolean; displayLegacyHeader: boolean;
language?: string; language?: string;
overrideFormatLocale?: string; overrideFormatLocale?: string;
duplicateSettings: DuplicateSettings;
}; };
/** /**
@@ -34,6 +42,12 @@ export function useViewPreferences(): Ref<LocationViewPreferences> {
displayLegacyHeader: false, displayLegacyHeader: false,
language: null, language: null,
overrideFormatLocale: null, overrideFormatLocale: null,
duplicateSettings: {
copyMaintenance: false,
copyAttachments: true,
copyCustomFields: true,
copyPrefixOverride: null,
},
}, },
{ mergeDefaults: true } { mergeDefaults: true }
); );

View File

@@ -153,6 +153,26 @@ export class ItemsApi extends BaseAPI {
return resp; return resp;
} }
duplicate(
id: string,
options: {
copyMaintenance?: boolean;
copyAttachments?: boolean;
copyCustomFields?: boolean;
copyPrefix?: string;
} = {}
) {
return this.http.post<typeof options, ItemOut>({
url: route(`/items/${id}/duplicate`),
body: {
copyMaintenance: options.copyMaintenance,
copyAttachments: options.copyAttachments,
copyCustomFields: options.copyCustomFields,
copyPrefix: options.copyPrefix,
},
});
}
import(file: File | Blob) { import(file: File | Blob) {
const formData = new FormData(); const formData = new FormData();
formData.append("csv", file); formData.append("csv", file);

View File

@@ -454,10 +454,6 @@ export interface EntUserEdges {
export interface BarcodeProduct { export interface BarcodeProduct {
barcode: string; barcode: string;
imageBase64: string; imageBase64: string;
/**
* TODO: add image attachement
* TODO: add asin?
*/
imageURL: string; imageURL: string;
item: ItemCreate; item: ItemCreate;
manufacturer: string; manufacturer: string;

View File

@@ -289,6 +289,18 @@
"delete_attachment_confirm": "Are you sure you want to delete this attachment?", "delete_attachment_confirm": "Are you sure you want to delete this attachment?",
"delete_item_confirm": "Are you sure you want to delete this item?", "delete_item_confirm": "Are you sure you want to delete this item?",
"description": "Description", "description": "Description",
"duplicate": {
"prefix": "Copy of ",
"copy_maintenance": "Copy Maintenance",
"copy_attachments": "Copy Attachments",
"copy_custom_fields": "Copy Custom Fields",
"custom_prefix": "Copy Prefix",
"enable_custom_prefix": "Enable Custom Prefix",
"prefix_instructions": "This prefix will be added to the beginning of the duplicated item's name. Include a space at the end of the prefix to add a space between the prefix and the item name.",
"temporary_title": "Temporary Settings",
"title": "Duplicate Settings",
"override_instructions": "Hold shift when clicking the duplicate button to override these settings."
},
"details": "Details", "details": "Details",
"drag_and_drop": "Drag and drop files here or click to select files", "drag_and_drop": "Drag and drop files here or click to select files",
"edit": { "edit": {

View File

@@ -42,6 +42,13 @@
const itemId = computed<string>(() => route.params.id as string); const itemId = computed<string>(() => route.params.id as string);
const preferences = useViewPreferences(); const preferences = useViewPreferences();
const temporaryDuplicateSettings = ref<DuplicateSettings>({
copyMaintenance: preferences.value.duplicateSettings.copyMaintenance,
copyAttachments: preferences.value.duplicateSettings.copyAttachments,
copyCustomFields: preferences.value.duplicateSettings.copyCustomFields,
copyPrefixOverride: preferences.value.duplicateSettings.copyPrefixOverride,
});
const hasNested = computed<boolean>(() => { const hasNested = computed<boolean>(() => {
return route.fullPath.split("/").at(-1) !== itemId.value; return route.fullPath.split("/").at(-1) !== itemId.value;
}); });
@@ -473,43 +480,43 @@
return resp.data.items; return resp.data.items;
}); });
async function duplicateItem() { async function duplicateItem(settings?: DuplicateSettings) {
if (!item.value) { if (!item.value) {
return; return;
} }
const { error, data } = await api.items.create({ const duplicateSettings = settings
name: `${item.value.name} Copy`, ? {
description: item.value.description, copyMaintenance: settings.copyMaintenance,
quantity: item.value.quantity, copyAttachments: settings.copyAttachments,
locationId: item.value.location!.id, copyCustomFields: settings.copyCustomFields,
parentId: item.value.parent?.id, copyPrefix: settings.copyPrefixOverride ?? t("items.duplicate.prefix"),
labelIds: item.value.labels.map(l => l.id), }
}); : {
copyMaintenance: preferences.value.duplicateSettings.copyMaintenance,
copyAttachments: preferences.value.duplicateSettings.copyAttachments,
copyCustomFields: preferences.value.duplicateSettings.copyCustomFields,
copyPrefix: preferences.value.duplicateSettings.copyPrefixOverride ?? t("items.duplicate.prefix"),
};
const { error, data } = await api.items.duplicate(itemId.value, duplicateSettings);
if (error) { if (error) {
toast.error(t("items.toast.failed_duplicate_item")); toast.error(t("items.toast.failed_duplicate_item"));
return; return;
} }
// add extra fields
const { error: updateError } = await api.items.update(data.id, {
...item.value,
id: data.id,
labelIds: data.labels.map(l => l.id),
locationId: data.location!.id,
name: data.name,
assetId: data.assetId,
});
if (updateError) {
toast.error(t("items.toast.failed_duplicate_item"));
return;
}
navigateTo(`/item/${data.id}`); navigateTo(`/item/${data.id}`);
} }
function handleDuplicateClick(event: MouseEvent) {
if (event.shiftKey) {
openDialog(DialogID.DuplicateTemporarySettings);
} else {
duplicateItem();
}
}
const confirm = useConfirm(); const confirm = useConfirm();
async function deleteItem() { async function deleteItem() {
@@ -545,6 +552,25 @@
<!-- set page title --> <!-- set page title -->
<Title>{{ item.name }}</Title> <Title>{{ item.name }}</Title>
<Dialog :dialog-id="DialogID.DuplicateTemporarySettings">
<DialogContent>
<DialogHeader>
<DialogTitle>{{ $t("items.duplicate.temporary_title") }}</DialogTitle>
</DialogHeader>
<ItemDuplicateSettings v-model="temporaryDuplicateSettings" />
<DialogFooter>
<Button
@click="
closeDialog(DialogID.DuplicateTemporarySettings);
duplicateItem(temporaryDuplicateSettings);
"
>
{{ $t("global.duplicate") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog :dialog-id="DialogID.ItemImage"> <Dialog :dialog-id="DialogID.ItemImage">
<DialogContent class="w-auto border-transparent bg-transparent p-0" disable-close> <DialogContent class="w-auto border-transparent bg-transparent p-0" disable-close>
<picture> <picture>
@@ -623,7 +649,7 @@
<MdiPlus /> <MdiPlus />
<span class="hidden md:inline">{{ $t("global.create_subitem") }}</span> <span class="hidden md:inline">{{ $t("global.create_subitem") }}</span>
</Button> </Button>
<Button class="w-9 md:w-auto" :aria-label="$t('global.duplicate')" @click="duplicateItem"> <Button class="w-9 md:w-auto" :aria-label="$t('global.duplicate')" @click="handleDuplicateClick">
<MdiContentCopy /> <MdiContentCopy />
<span class="hidden md:inline">{{ $t("global.duplicate") }}</span> <span class="hidden md:inline">{{ $t("global.duplicate") }}</span>
</Button> </Button>

View File

@@ -305,6 +305,18 @@
<template> <template>
<div> <div>
<Dialog :dialog-id="DialogID.DuplicateSettings">
<DialogContent>
<DialogHeader>
<DialogTitle>{{ $t("items.duplicate.title") }}</DialogTitle>
</DialogHeader>
<ItemDuplicateSettings v-model="preferences.duplicateSettings" />
<p class="text-sm text-muted-foreground">
{{ $t("items.duplicate.override_instructions") }}
</p>
</DialogContent>
</Dialog>
<Dialog :dialog-id="DialogID.ChangePassword"> <Dialog :dialog-id="DialogID.ChangePassword">
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -376,6 +388,9 @@
{{ $t("profile.change_password") }} {{ $t("profile.change_password") }}
</Button> </Button>
<Button variant="secondary" size="sm" @click="generateToken"> {{ $t("profile.gen_invite") }} </Button> <Button variant="secondary" size="sm" @click="generateToken"> {{ $t("profile.gen_invite") }} </Button>
<Button variant="secondary" size="sm" @click="openDialog(DialogID.DuplicateSettings)">
{{ $t("items.duplicate.title") }}
</Button>
</div> </div>
<div v-if="token" class="flex items-center gap-2 pl-1 pt-4"> <div v-if="token" class="flex items-center gap-2 pl-1 pt-4">
<CopyText :text="tokenUrl" /> <CopyText :text="tokenUrl" />