mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-21 13:23:14 +01:00
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:
@@ -254,6 +254,25 @@ func (ctrl *V1Controller) HandleItemPatch() errchain.HandlerFunc {
|
||||
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
|
||||
//
|
||||
// @Summary Get All Custom Field Names
|
||||
|
||||
@@ -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.Patch("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemPatch(), 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.Put("/items/{id}/attachments/{attachment_id}", chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentUpdate(), userMW...))
|
||||
|
||||
@@ -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": {
|
||||
"get": {
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -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": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -3127,6 +3169,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.DuplicateOptions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"copyAttachments": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"copyCustomFields": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"copyMaintenance": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"copyPrefix": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.Group": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -667,6 +667,17 @@ definitions:
|
||||
search_engine_name:
|
||||
type: string
|
||||
type: object
|
||||
repo.DuplicateOptions:
|
||||
properties:
|
||||
copyAttachments:
|
||||
type: boolean
|
||||
copyCustomFields:
|
||||
type: boolean
|
||||
copyMaintenance:
|
||||
type: boolean
|
||||
copyPrefix:
|
||||
type: string
|
||||
type: object
|
||||
repo.Group:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -1968,6 +1979,32 @@ paths:
|
||||
summary: Update Item Attachment
|
||||
tags:
|
||||
- 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:
|
||||
get:
|
||||
parameters:
|
||||
|
||||
@@ -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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
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/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4=
|
||||
github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
|
||||
|
||||
@@ -38,6 +38,10 @@ func (svc *ItemService) Create(ctx Context, item repo.ItemCreate) (repo.ItemOut,
|
||||
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) {
|
||||
items, err := svc.repo.Items.GetAllZeroAssetID(ctx, gid)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"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/data/ent"
|
||||
"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/label"
|
||||
"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/types"
|
||||
)
|
||||
@@ -46,6 +48,13 @@ type (
|
||||
OrderBy string `json:"orderBy"`
|
||||
}
|
||||
|
||||
DuplicateOptions struct {
|
||||
CopyMaintenance bool `json:"copyMaintenance"`
|
||||
CopyAttachments bool `json:"copyAttachments"`
|
||||
CopyCustomFields bool `json:"copyCustomFields"`
|
||||
CopyPrefix string `json:"copyPrefix"`
|
||||
}
|
||||
|
||||
ItemField struct {
|
||||
ID uuid.UUID `json:"id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
@@ -1004,3 +1013,164 @@ func (e *ItemsRepository) SetPrimaryPhotos(ctx context.Context, gid uuid.UUID) (
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -3127,6 +3169,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.DuplicateOptions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"copyAttachments": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"copyCustomFields": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"copyMaintenance": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"copyPrefix": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repo.Group": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -667,6 +667,17 @@ definitions:
|
||||
search_engine_name:
|
||||
type: string
|
||||
type: object
|
||||
repo.DuplicateOptions:
|
||||
properties:
|
||||
copyAttachments:
|
||||
type: boolean
|
||||
copyCustomFields:
|
||||
type: boolean
|
||||
copyMaintenance:
|
||||
type: boolean
|
||||
copyPrefix:
|
||||
type: string
|
||||
type: object
|
||||
repo.Group:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -1968,6 +1979,32 @@ paths:
|
||||
summary: Update Item Attachment
|
||||
tags:
|
||||
- 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:
|
||||
get:
|
||||
parameters:
|
||||
|
||||
83
frontend/components/Item/DuplicateSettings.vue
Normal file
83
frontend/components/Item/DuplicateSettings.vue
Normal 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>
|
||||
@@ -10,6 +10,8 @@ export enum DialogID {
|
||||
CreateLocation = 'create-location',
|
||||
CreateLabel = 'create-label',
|
||||
CreateNotifier = 'create-notifier',
|
||||
DuplicateSettings = 'duplicate-settings',
|
||||
DuplicateTemporarySettings = 'duplicate-temporary-settings',
|
||||
EditMaintenance = 'edit-maintenance',
|
||||
Import = 'import',
|
||||
ItemImage = 'item-image',
|
||||
|
||||
@@ -4,6 +4,13 @@ import type { DaisyTheme } from "~~/lib/data/themes";
|
||||
|
||||
export type ViewType = "table" | "card" | "tree";
|
||||
|
||||
export type DuplicateSettings = {
|
||||
copyMaintenance: boolean;
|
||||
copyAttachments: boolean;
|
||||
copyCustomFields: boolean;
|
||||
copyPrefixOverride: string | null;
|
||||
};
|
||||
|
||||
export type LocationViewPreferences = {
|
||||
showDetails: boolean;
|
||||
showEmpty: boolean;
|
||||
@@ -15,6 +22,7 @@ export type LocationViewPreferences = {
|
||||
displayLegacyHeader: boolean;
|
||||
language?: string;
|
||||
overrideFormatLocale?: string;
|
||||
duplicateSettings: DuplicateSettings;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -34,6 +42,12 @@ export function useViewPreferences(): Ref<LocationViewPreferences> {
|
||||
displayLegacyHeader: false,
|
||||
language: null,
|
||||
overrideFormatLocale: null,
|
||||
duplicateSettings: {
|
||||
copyMaintenance: false,
|
||||
copyAttachments: true,
|
||||
copyCustomFields: true,
|
||||
copyPrefixOverride: null,
|
||||
},
|
||||
},
|
||||
{ mergeDefaults: true }
|
||||
);
|
||||
|
||||
@@ -153,6 +153,26 @@ export class ItemsApi extends BaseAPI {
|
||||
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) {
|
||||
const formData = new FormData();
|
||||
formData.append("csv", file);
|
||||
|
||||
@@ -454,10 +454,6 @@ export interface EntUserEdges {
|
||||
export interface BarcodeProduct {
|
||||
barcode: string;
|
||||
imageBase64: string;
|
||||
/**
|
||||
* TODO: add image attachement
|
||||
* TODO: add asin?
|
||||
*/
|
||||
imageURL: string;
|
||||
item: ItemCreate;
|
||||
manufacturer: string;
|
||||
|
||||
@@ -289,6 +289,18 @@
|
||||
"delete_attachment_confirm": "Are you sure you want to delete this attachment?",
|
||||
"delete_item_confirm": "Are you sure you want to delete this item?",
|
||||
"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",
|
||||
"drag_and_drop": "Drag and drop files here or click to select files",
|
||||
"edit": {
|
||||
|
||||
@@ -42,6 +42,13 @@
|
||||
const itemId = computed<string>(() => route.params.id as string);
|
||||
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>(() => {
|
||||
return route.fullPath.split("/").at(-1) !== itemId.value;
|
||||
});
|
||||
@@ -473,43 +480,43 @@
|
||||
return resp.data.items;
|
||||
});
|
||||
|
||||
async function duplicateItem() {
|
||||
async function duplicateItem(settings?: DuplicateSettings) {
|
||||
if (!item.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error, data } = await api.items.create({
|
||||
name: `${item.value.name} Copy`,
|
||||
description: item.value.description,
|
||||
quantity: item.value.quantity,
|
||||
locationId: item.value.location!.id,
|
||||
parentId: item.value.parent?.id,
|
||||
labelIds: item.value.labels.map(l => l.id),
|
||||
});
|
||||
const duplicateSettings = settings
|
||||
? {
|
||||
copyMaintenance: settings.copyMaintenance,
|
||||
copyAttachments: settings.copyAttachments,
|
||||
copyCustomFields: settings.copyCustomFields,
|
||||
copyPrefix: settings.copyPrefixOverride ?? t("items.duplicate.prefix"),
|
||||
}
|
||||
: {
|
||||
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) {
|
||||
toast.error(t("items.toast.failed_duplicate_item"));
|
||||
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}`);
|
||||
}
|
||||
|
||||
function handleDuplicateClick(event: MouseEvent) {
|
||||
if (event.shiftKey) {
|
||||
openDialog(DialogID.DuplicateTemporarySettings);
|
||||
} else {
|
||||
duplicateItem();
|
||||
}
|
||||
}
|
||||
|
||||
const confirm = useConfirm();
|
||||
|
||||
async function deleteItem() {
|
||||
@@ -545,6 +552,25 @@
|
||||
<!-- set page 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">
|
||||
<DialogContent class="w-auto border-transparent bg-transparent p-0" disable-close>
|
||||
<picture>
|
||||
@@ -623,7 +649,7 @@
|
||||
<MdiPlus />
|
||||
<span class="hidden md:inline">{{ $t("global.create_subitem") }}</span>
|
||||
</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 />
|
||||
<span class="hidden md:inline">{{ $t("global.duplicate") }}</span>
|
||||
</Button>
|
||||
|
||||
@@ -305,6 +305,18 @@
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -376,6 +388,9 @@
|
||||
{{ $t("profile.change_password") }}
|
||||
</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 v-if="token" class="flex items-center gap-2 pl-1 pt-4">
|
||||
<CopyText :text="tokenUrl" />
|
||||
|
||||
Reference in New Issue
Block a user