mirror of
https://github.com/hay-kot/homebox.git
synced 2025-08-03 08:10:28 +00:00
WIP item update client side actions
This commit is contained in:
parent
16934b5bcc
commit
b8cee37fdc
20 changed files with 595 additions and 93 deletions
23
Taskfile.yml
23
Taskfile.yml
|
@ -5,7 +5,6 @@ tasks:
|
|||
cmds:
|
||||
- |
|
||||
cd backend && ent generate ./ent/schema \
|
||||
--template=ent/schema/templates/stringer.tmpl \
|
||||
--template=ent/schema/templates/has_id.tmpl
|
||||
- cd backend/app/api/ && swag fmt
|
||||
- cd backend/app/api/ && swag init --dir=./,../../internal,../../pkgs
|
||||
|
@ -17,18 +16,12 @@ tasks:
|
|||
--path ./backend/app/api/docs/swagger.json \
|
||||
--output ./frontend/lib/api/types
|
||||
|
||||
# Output cleanup for the generated types
|
||||
# 1. Remove the prefix `Types` from each type generated
|
||||
# 2. Remove the ?: from the type definition since types are not properly annotated in the swagger.json
|
||||
|
||||
python3 ./scripts/process-types.py ./frontend/lib/api/types/data-contracts.ts
|
||||
api:
|
||||
cmds:
|
||||
- task: generate
|
||||
- cd backend && go run ./app/api/ {{.CLI_ARGS}}
|
||||
silent: false
|
||||
sources:
|
||||
- ./backend/**/*.go
|
||||
|
||||
api:build:
|
||||
cmds:
|
||||
|
@ -40,6 +33,10 @@ tasks:
|
|||
- cd backend && go test ./app/api/
|
||||
silent: true
|
||||
|
||||
api:watch:
|
||||
cmds:
|
||||
- cd backend && gotestsum --watch ./...
|
||||
|
||||
api:coverage:
|
||||
cmds:
|
||||
- cd backend && go test -race -coverprofile=coverage.out -covermode=atomic ./app/... ./internal/... ./pkgs/... -v -cover
|
||||
|
@ -53,12 +50,12 @@ tasks:
|
|||
- cd frontend && pnpm run test:ci
|
||||
silent: true
|
||||
|
||||
docker:build:
|
||||
frontend:watch:
|
||||
desc: Starts the vitest test runner in watch mode
|
||||
cmds:
|
||||
- cd backend && docker-compose up --build
|
||||
silent: true
|
||||
- cd frontend && pnpm vitest --watch
|
||||
|
||||
generate:types:
|
||||
frontend:
|
||||
desc: Run frontend development server
|
||||
cmds:
|
||||
- cd backend && go run ./app/generator
|
||||
silent: true
|
||||
- cd frontend && pnpm dev
|
||||
|
|
|
@ -175,6 +175,15 @@ const docTemplate = `{
|
|||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Item Data",
|
||||
"name": "payload",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/types.ItemUpdate"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
@ -1053,6 +1062,83 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"types.ItemUpdate": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"insured": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"labelIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"lifetimeWarranty": {
|
||||
"description": "Warranty",
|
||||
"type": "boolean"
|
||||
},
|
||||
"locationId": {
|
||||
"description": "Edges",
|
||||
"type": "string"
|
||||
},
|
||||
"manufacturer": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelNumber": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"notes": {
|
||||
"description": "Extras",
|
||||
"type": "string"
|
||||
},
|
||||
"purchaseFrom": {
|
||||
"type": "string"
|
||||
},
|
||||
"purchasePrice": {
|
||||
"type": "number"
|
||||
},
|
||||
"purchaseTime": {
|
||||
"description": "Purchase",
|
||||
"type": "string"
|
||||
},
|
||||
"quantity": {
|
||||
"type": "integer"
|
||||
},
|
||||
"serialNumber": {
|
||||
"description": "Identifications",
|
||||
"type": "string"
|
||||
},
|
||||
"soldNotes": {
|
||||
"type": "string"
|
||||
},
|
||||
"soldPrice": {
|
||||
"type": "number"
|
||||
},
|
||||
"soldTime": {
|
||||
"description": "Sold",
|
||||
"type": "string"
|
||||
},
|
||||
"soldTo": {
|
||||
"type": "string"
|
||||
},
|
||||
"warrantyDetails": {
|
||||
"type": "string"
|
||||
},
|
||||
"warrantyExpires": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"types.LabelCreate": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
@ -167,6 +167,15 @@
|
|||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Item Data",
|
||||
"name": "payload",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/types.ItemUpdate"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
@ -1045,6 +1054,83 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"types.ItemUpdate": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"insured": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"labelIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"lifetimeWarranty": {
|
||||
"description": "Warranty",
|
||||
"type": "boolean"
|
||||
},
|
||||
"locationId": {
|
||||
"description": "Edges",
|
||||
"type": "string"
|
||||
},
|
||||
"manufacturer": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelNumber": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"notes": {
|
||||
"description": "Extras",
|
||||
"type": "string"
|
||||
},
|
||||
"purchaseFrom": {
|
||||
"type": "string"
|
||||
},
|
||||
"purchasePrice": {
|
||||
"type": "number"
|
||||
},
|
||||
"purchaseTime": {
|
||||
"description": "Purchase",
|
||||
"type": "string"
|
||||
},
|
||||
"quantity": {
|
||||
"type": "integer"
|
||||
},
|
||||
"serialNumber": {
|
||||
"description": "Identifications",
|
||||
"type": "string"
|
||||
},
|
||||
"soldNotes": {
|
||||
"type": "string"
|
||||
},
|
||||
"soldPrice": {
|
||||
"type": "number"
|
||||
},
|
||||
"soldTime": {
|
||||
"description": "Sold",
|
||||
"type": "string"
|
||||
},
|
||||
"soldTo": {
|
||||
"type": "string"
|
||||
},
|
||||
"warrantyDetails": {
|
||||
"type": "string"
|
||||
},
|
||||
"warrantyExpires": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"types.LabelCreate": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
@ -179,6 +179,59 @@ definitions:
|
|||
warrantyExpires:
|
||||
type: string
|
||||
type: object
|
||||
types.ItemUpdate:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
insured:
|
||||
type: boolean
|
||||
labelIds:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
lifetimeWarranty:
|
||||
description: Warranty
|
||||
type: boolean
|
||||
locationId:
|
||||
description: Edges
|
||||
type: string
|
||||
manufacturer:
|
||||
type: string
|
||||
modelNumber:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
notes:
|
||||
description: Extras
|
||||
type: string
|
||||
purchaseFrom:
|
||||
type: string
|
||||
purchasePrice:
|
||||
type: number
|
||||
purchaseTime:
|
||||
description: Purchase
|
||||
type: string
|
||||
quantity:
|
||||
type: integer
|
||||
serialNumber:
|
||||
description: Identifications
|
||||
type: string
|
||||
soldNotes:
|
||||
type: string
|
||||
soldPrice:
|
||||
type: number
|
||||
soldTime:
|
||||
description: Sold
|
||||
type: string
|
||||
soldTo:
|
||||
type: string
|
||||
warrantyDetails:
|
||||
type: string
|
||||
warrantyExpires:
|
||||
type: string
|
||||
type: object
|
||||
types.LabelCreate:
|
||||
properties:
|
||||
color:
|
||||
|
@ -415,6 +468,12 @@ paths:
|
|||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Item Data
|
||||
in: body
|
||||
name: payload
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/types.ItemUpdate'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
|
|
|
@ -64,7 +64,7 @@ func (ctrl *V1Controller) HandleItemsCreate() http.HandlerFunc {
|
|||
// @Summary deletes a item
|
||||
// @Tags Items
|
||||
// @Produce json
|
||||
// @Param id path string true "Item ID"
|
||||
// @Param id path string true "Item ID"
|
||||
// @Success 204
|
||||
// @Router /v1/items/{id} [DELETE]
|
||||
// @Security Bearer
|
||||
|
@ -90,7 +90,7 @@ func (ctrl *V1Controller) HandleItemDelete() http.HandlerFunc {
|
|||
// @Tags Items
|
||||
// @Produce json
|
||||
// @Param id path string true "Item ID"
|
||||
// @Success 200 {object} types.ItemOut
|
||||
// @Success 200 {object} types.ItemOut
|
||||
// @Router /v1/items/{id} [GET]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleItemGet() http.HandlerFunc {
|
||||
|
@ -115,6 +115,7 @@ func (ctrl *V1Controller) HandleItemGet() http.HandlerFunc {
|
|||
// @Tags Items
|
||||
// @Produce json
|
||||
// @Param id path string true "Item ID"
|
||||
// @Param payload body types.ItemUpdate true "Item Data"
|
||||
// @Success 200 {object} types.ItemOut
|
||||
// @Router /v1/items/{id} [PUT]
|
||||
// @Security Bearer
|
||||
|
|
|
@ -9,7 +9,7 @@ import (
|
|||
// body is decoded into the provided value.
|
||||
func Decode(r *http.Request, val interface{}) error {
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
// decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(val); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
{{ dKey }}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
||||
<slot :name="dKey" v-bind="{ key: dKey, value: dValue }">
|
||||
<slot :name="rmSpace(dKey)" v-bind="{ key: dKey, value: dValue }">
|
||||
{{ dValue }}
|
||||
</slot>
|
||||
</dd>
|
||||
|
@ -28,6 +28,10 @@
|
|||
<script setup lang="ts">
|
||||
type StringLike = string | number | boolean;
|
||||
|
||||
function rmSpace(str: string) {
|
||||
return str.replace(" ", "");
|
||||
}
|
||||
|
||||
defineProps({
|
||||
details: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
|
@ -52,9 +52,14 @@
|
|||
|
||||
const selected = useVModel(props, "modelValue", emit);
|
||||
const dateText = computed(() => {
|
||||
if (!validDate(selected.value)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (selected.value) {
|
||||
return selected.value.toLocaleDateString();
|
||||
}
|
||||
|
||||
return "";
|
||||
});
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
v-for="(obj, idx) in items"
|
||||
:key="idx"
|
||||
:class="{
|
||||
bordered: selectedIndexes[idx],
|
||||
bordered: selected[idx],
|
||||
}"
|
||||
>
|
||||
<button type="button" @click="toggle(idx)">
|
||||
|
@ -56,28 +56,23 @@
|
|||
},
|
||||
});
|
||||
|
||||
const selectedIndexes = ref<Record<number, boolean>>({});
|
||||
const value = useVModel(props, "modelValue", emit);
|
||||
|
||||
const selected = computed<Record<number, boolean>>(() => {
|
||||
const obj: Record<number, boolean> = {};
|
||||
value.value.forEach(itm => {
|
||||
const idx = props.items.findIndex(item => item[props.name] === itm.name);
|
||||
obj[idx] = true;
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
|
||||
function toggle(index: number) {
|
||||
selectedIndexes.value[index] = !selectedIndexes.value[index];
|
||||
|
||||
const item = props.items[index];
|
||||
|
||||
if (selectedIndexes.value[index]) {
|
||||
value.value = [...value.value, item];
|
||||
if (selected.value[index]) {
|
||||
value.value = value.value.filter(itm => itm.name !== item.name);
|
||||
} else {
|
||||
value.value = value.value.filter(itm => itm !== item);
|
||||
value.value = [...value.value, item];
|
||||
}
|
||||
}
|
||||
|
||||
watchOnce(
|
||||
() => props.items,
|
||||
() => {
|
||||
if (props.selectFirst && props.items.length > 0) {
|
||||
value.value = props.items[0];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const value = useVModel(props, "modelValue", emit);
|
||||
</script>
|
||||
|
|
|
@ -3,9 +3,9 @@
|
|||
<label class="label">
|
||||
<span class="label-text">{{ label }}</span>
|
||||
</label>
|
||||
<select v-model="value" class="select select-bordered">
|
||||
<select v-model="selectedIdx" class="select select-bordered">
|
||||
<option disabled selected>Pick one</option>
|
||||
<option v-for="obj in items" :key="name != '' ? obj[name] : obj" :value="obj">
|
||||
<option v-for="(obj, idx) in items" :key="name != '' ? obj[name] : obj" :value="idx">
|
||||
{{ name != "" ? obj[name] : obj }}
|
||||
</option>
|
||||
</select>
|
||||
|
@ -47,10 +47,16 @@
|
|||
() => props.items,
|
||||
() => {
|
||||
if (props.selectFirst && props.items.length > 0) {
|
||||
value.value = props.items[0];
|
||||
selectedIdx.value = 0;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const value = useVModel(props, "modelValue", emit);
|
||||
const selectedIdx = ref(0);
|
||||
watch(
|
||||
() => selectedIdx.value,
|
||||
() => {
|
||||
emit("update:modelValue", props.items[selectedIdx.value]);
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
|
|
@ -22,11 +22,11 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ItemOut } from "~~/lib/api/types/data-contracts";
|
||||
import { ItemOut, ItemSummary } from "~~/lib/api/types/data-contracts";
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object as () => ItemOut,
|
||||
type: Object as () => ItemOut | ItemSummary,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -26,7 +26,8 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type Location } from "~~/lib/api/classes/locations";
|
||||
import { ItemCreate, LocationOut } from "~~/lib/api/types/data-contracts";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
|
@ -40,7 +41,7 @@
|
|||
const loading = ref(false);
|
||||
const focused = ref(false);
|
||||
const form = reactive({
|
||||
location: {} as Location,
|
||||
location: {} as LocationOut,
|
||||
name: "",
|
||||
description: "",
|
||||
color: "", // Future!
|
||||
|
@ -80,7 +81,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
const out = {
|
||||
const out: ItemCreate = {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
locationId: form.location.id as string,
|
||||
|
|
52
frontend/components/global/DateTime.vue
Normal file
52
frontend/components/global/DateTime.vue
Normal file
|
@ -0,0 +1,52 @@
|
|||
<template>
|
||||
{{ value }}
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
enum DateTimeFormat {
|
||||
RELATIVE = "relative",
|
||||
LONG = "long",
|
||||
SHORT = "short",
|
||||
}
|
||||
|
||||
const value = computed(() => {
|
||||
if (!props.date) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const dt = typeof props.date === "string" ? new Date(props.date) : props.date;
|
||||
if (!dt) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (nullDate(dt)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
switch (props.format) {
|
||||
case DateTimeFormat.RELATIVE:
|
||||
return useTimeAgo(dt).value + useDateFormat(dt, " (MM-DD-YYYY)").value;
|
||||
case DateTimeFormat.LONG:
|
||||
return useDateFormat(dt, "YYYY-MM-DD (dddd)").value;
|
||||
case DateTimeFormat.SHORT:
|
||||
return useDateFormat(dt, "YYYY-MM-DD").value;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
function nullDate(dt: Date) {
|
||||
return dt.getFullYear() === 1;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
date: {
|
||||
type: [Date, String],
|
||||
required: true,
|
||||
},
|
||||
format: {
|
||||
type: String as () => DateTimeFormat,
|
||||
default: "relative",
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -3,6 +3,7 @@ import { Ref } from "vue";
|
|||
export type LocationViewPreferences = {
|
||||
showDetails: boolean;
|
||||
showEmpty: boolean;
|
||||
editorSimpleView: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -15,6 +16,7 @@ export function useViewPreferences(): Ref<LocationViewPreferences> {
|
|||
{
|
||||
showDetails: true,
|
||||
showEmpty: true,
|
||||
editorSimpleView: true,
|
||||
},
|
||||
{ mergeDefaults: true }
|
||||
);
|
||||
|
|
22
frontend/composables/utils.ts
Normal file
22
frontend/composables/utils.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
export function validDate(dt: Date | string | null | undefined): boolean {
|
||||
if (!dt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it's a string, try to parse it
|
||||
if (typeof dt === "string") {
|
||||
const parsed = new Date(dt);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If it's a date, check if it's valid
|
||||
if (dt instanceof Date) {
|
||||
if (dt.getFullYear() < 1000) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
|
@ -36,4 +36,26 @@ export class BaseAPI {
|
|||
constructor(requests: Requests) {
|
||||
this.http = requests;
|
||||
}
|
||||
|
||||
/**
|
||||
* dropFields will remove any fields that are specified in the fields array
|
||||
* additionally, it will remove the `createdAt` and `updatedAt` fields if they
|
||||
* are present. This is useful for when you want to send a subset of fields to
|
||||
* the server like when performing an update.
|
||||
*/
|
||||
dropFields<T>(obj: T, keys: Array<keyof T> = []): T {
|
||||
const result = { ...obj };
|
||||
console.log("dropFields", result);
|
||||
[...keys, "createdAt", "updatedAt"].forEach(key => {
|
||||
console.log(key);
|
||||
// @ts-ignore - we are checking for the key above
|
||||
if (hasKey(result, key)) {
|
||||
// @ts-ignore - we are guarding against this above
|
||||
delete result[key];
|
||||
console.log("dropping", key);
|
||||
}
|
||||
});
|
||||
console.log("dropFields", result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { BaseAPI, route } from "../base";
|
||||
import { parseDate } from "../base/base-api";
|
||||
import { ItemCreate, ItemOut } from "../types/data-contracts";
|
||||
import { ItemCreate, ItemOut, ItemSummary, ItemUpdate } from "../types/data-contracts";
|
||||
import { Results } from "./types";
|
||||
|
||||
export class ItemsApi extends BaseAPI {
|
||||
|
@ -9,7 +9,7 @@ export class ItemsApi extends BaseAPI {
|
|||
}
|
||||
|
||||
create(item: ItemCreate) {
|
||||
return this.http.post<ItemCreate, ItemOut>({ url: route("/items"), body: item });
|
||||
return this.http.post<ItemCreate, ItemSummary>({ url: route("/items"), body: item });
|
||||
}
|
||||
|
||||
async get(id: string) {
|
||||
|
@ -28,8 +28,17 @@ export class ItemsApi extends BaseAPI {
|
|||
return this.http.delete<void>({ url: route(`/items/${id}`) });
|
||||
}
|
||||
|
||||
update(id: string, item: ItemCreate) {
|
||||
return this.http.put<ItemCreate, ItemOut>({ url: route(`/items/${id}`), body: item });
|
||||
async update(id: string, item: ItemUpdate) {
|
||||
const payload = await this.http.put<ItemCreate, ItemOut>({
|
||||
url: route(`/items/${id}`),
|
||||
body: this.dropFields(item),
|
||||
});
|
||||
if (!payload.data) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
payload.data = parseDate(payload.data, ["purchaseTime", "soldTime", "warrantyExpires"]);
|
||||
return payload;
|
||||
}
|
||||
|
||||
import(file: File) {
|
||||
|
|
|
@ -127,6 +127,42 @@ export interface ItemSummary {
|
|||
warrantyExpires: Date;
|
||||
}
|
||||
|
||||
export interface ItemUpdate {
|
||||
description: string;
|
||||
id: string;
|
||||
insured: boolean;
|
||||
labelIds: string[];
|
||||
|
||||
/** Warranty */
|
||||
lifetimeWarranty: boolean;
|
||||
|
||||
/** Edges */
|
||||
locationId: string;
|
||||
manufacturer: string;
|
||||
modelNumber: string;
|
||||
name: string;
|
||||
|
||||
/** Extras */
|
||||
notes: string;
|
||||
purchaseFrom: string;
|
||||
purchasePrice: number;
|
||||
|
||||
/** Purchase */
|
||||
purchaseTime: Date;
|
||||
quantity: number;
|
||||
|
||||
/** Identifications */
|
||||
serialNumber: string;
|
||||
soldNotes: string;
|
||||
soldPrice: number;
|
||||
|
||||
/** Sold */
|
||||
soldTime: Date;
|
||||
soldTo: string;
|
||||
warrantyDetails: string;
|
||||
warrantyExpires: Date;
|
||||
}
|
||||
|
||||
export interface LabelCreate {
|
||||
color: string;
|
||||
description: string;
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { ItemUpdate } from "~~/lib/api/types/data-contracts";
|
||||
|
||||
definePageMeta({
|
||||
layout: "home",
|
||||
});
|
||||
|
@ -6,9 +8,25 @@
|
|||
const route = useRoute();
|
||||
const api = useUserApi();
|
||||
const toast = useNotifier();
|
||||
const preferences = useViewPreferences();
|
||||
|
||||
const itemId = computed<string>(() => route.params.id as string);
|
||||
|
||||
const originalItem = ref({
|
||||
name: "",
|
||||
quantity: 0,
|
||||
});
|
||||
|
||||
const { data: locations } = useAsyncData(async () => {
|
||||
const { data } = await api.locations.getAll();
|
||||
return data.items;
|
||||
});
|
||||
|
||||
const { data: labels } = useAsyncData(async () => {
|
||||
const { data } = await api.labels.getAll();
|
||||
return data.items;
|
||||
});
|
||||
|
||||
const { data: item } = useAsyncData(async () => {
|
||||
const { data, error } = await api.items.get(itemId.value);
|
||||
if (error) {
|
||||
|
@ -16,11 +34,37 @@
|
|||
navigateTo("/home");
|
||||
return;
|
||||
}
|
||||
|
||||
originalItem.value = {
|
||||
name: data.name,
|
||||
quantity: data.quantity,
|
||||
};
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
async function saveItem() {
|
||||
const payload: ItemUpdate = {
|
||||
...item.value,
|
||||
locationId: item.value.location?.id,
|
||||
labelIds: item.value.labels.map(l => l.id),
|
||||
};
|
||||
|
||||
console.log(payload);
|
||||
|
||||
const { error } = await api.items.update(itemId.value, payload);
|
||||
|
||||
if (error) {
|
||||
toast.error("Failed to save item");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Item saved");
|
||||
navigateTo("/item/" + itemId.value);
|
||||
}
|
||||
|
||||
type FormField = {
|
||||
type: "text" | "textarea" | "select" | "date";
|
||||
type: "text" | "textarea" | "select" | "date" | "label" | "location";
|
||||
label: string;
|
||||
ref: string;
|
||||
};
|
||||
|
@ -97,51 +141,115 @@
|
|||
|
||||
<template>
|
||||
<BaseContainer v-if="item" class="pb-8">
|
||||
<div class="space-y-4">
|
||||
<div class="overflow-hidden 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">Item Details</h3>
|
||||
</div>
|
||||
<div class="border-t border-gray-300 sm:p-0">
|
||||
<div v-for="field in mainFields" :key="field.ref" class="sm:divide-y sm:divide-gray-300 grid grid-cols-1">
|
||||
<div class="pt-2 pb-4 sm:px-6 border-b border-gray-300">
|
||||
<FormTextArea v-if="field.type === 'textarea'" v-model="item[field.ref]" :label="field.label" inline />
|
||||
<FormTextField v-else-if="field.type === 'text'" v-model="item[field.ref]" :label="field.label" inline />
|
||||
<FormDatePicker v-else-if="field.type === 'date'" v-model="item[field.ref]" :label="field.label" inline />
|
||||
</div>
|
||||
<section class="px-3">
|
||||
<div class="space-y-4">
|
||||
<div class="overflow-hidden card bg-base-100 shadow-xl sm:rounded-lg">
|
||||
<BaseSectionHeader v-if="item" class="p-5">
|
||||
<Icon name="mdi-package-variant" class="-mt-1 mr-2 text-gray-600" />
|
||||
<span class="text-gray-600">
|
||||
{{ originalItem.name }}
|
||||
</span>
|
||||
<p class="text-sm text-gray-600 font-bold pb-0 mb-0">Quantity {{ originalItem.quantity }}</p>
|
||||
<template #after>
|
||||
<div v-if="item.labels && item.labels.length > 0" class="flex flex-wrap gap-3 mt-3">
|
||||
<LabelChip v-for="label in item.labels" :key="label.id" class="badge-primary" :label="label" />
|
||||
</div>
|
||||
<div class="modal-action mt-3">
|
||||
<div class="mr-auto tooltip" data-tip="Hide the cruft! ">
|
||||
<label class="label cursor-pointer mr-auto">
|
||||
<input v-model="preferences.editorSimpleView" type="checkbox" class="toggle toggle-primary" />
|
||||
<span class="label-text ml-4"> Simple View </span>
|
||||
</label>
|
||||
</div>
|
||||
<BaseButton size="sm" @click="saveItem">
|
||||
<template #icon>
|
||||
<Icon name="mdi-content-save-outline" />
|
||||
</template>
|
||||
Save
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
</BaseSectionHeader>
|
||||
<div class="px-5 mb-6">
|
||||
<FormSelect v-model="item.location" label="Location" :items="locations ?? []" select-first />
|
||||
<FormMultiselect v-model="item.labels" label="Labels" :items="labels ?? []" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-visible card bg-base-100 shadow-xl sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h3 class="text-lg font-medium leading-6">Purchase Details</h3>
|
||||
</div>
|
||||
<div class="border-t border-gray-300 sm:p-0">
|
||||
<div v-for="field in purchaseFields" :key="field.ref" class="sm:divide-y sm:divide-gray-300 grid grid-cols-1">
|
||||
<div class="pt-2 pb-4 sm:px-6 border-b border-gray-300">
|
||||
<FormTextArea v-if="field.type === 'textarea'" v-model="item[field.ref]" :label="field.label" inline />
|
||||
<FormTextField v-else-if="field.type === 'text'" v-model="item[field.ref]" :label="field.label" inline />
|
||||
<FormDatePicker v-else-if="field.type === 'date'" v-model="item[field.ref]" :label="field.label" inline />
|
||||
<div class="border-t border-gray-300 sm:p-0">
|
||||
<div v-for="field in mainFields" :key="field.ref" class="sm:divide-y sm:divide-gray-300 grid grid-cols-1">
|
||||
<div class="pt-2 pb-4 sm:px-6 border-b border-gray-300">
|
||||
<FormTextArea v-if="field.type === 'textarea'" v-model="item[field.ref]" :label="field.label" inline />
|
||||
<FormTextField
|
||||
v-else-if="field.type === 'text'"
|
||||
v-model="item[field.ref]"
|
||||
:label="field.label"
|
||||
inline
|
||||
/>
|
||||
<FormDatePicker
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="item[field.ref]"
|
||||
:label="field.label"
|
||||
inline
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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">Sold Details</h3>
|
||||
<div v-if="!preferences.editorSimpleView" class="overflow-visible card bg-base-100 shadow-xl sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h3 class="text-lg font-medium leading-6">Purchase Details</h3>
|
||||
</div>
|
||||
<div class="border-t border-gray-300 sm:p-0">
|
||||
<div
|
||||
v-for="field in purchaseFields"
|
||||
:key="field.ref"
|
||||
class="sm:divide-y sm:divide-gray-300 grid grid-cols-1"
|
||||
>
|
||||
<div class="pt-2 pb-4 sm:px-6 border-b border-gray-300">
|
||||
<FormTextArea v-if="field.type === 'textarea'" v-model="item[field.ref]" :label="field.label" inline />
|
||||
<FormTextField
|
||||
v-else-if="field.type === 'text'"
|
||||
v-model="item[field.ref]"
|
||||
:label="field.label"
|
||||
inline
|
||||
/>
|
||||
<FormDatePicker
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="item[field.ref]"
|
||||
:label="field.label"
|
||||
inline
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-300 sm:p-0">
|
||||
<div v-for="field in soldFields" :key="field.ref" class="sm:divide-y sm:divide-gray-300 grid grid-cols-1">
|
||||
<div class="pt-2 pb-4 sm:px-6 border-b border-gray-300">
|
||||
<FormTextArea v-if="field.type === 'textarea'" v-model="item[field.ref]" :label="field.label" inline />
|
||||
<FormTextField v-else-if="field.type === 'text'" v-model="item[field.ref]" :label="field.label" inline />
|
||||
<FormDatePicker v-else-if="field.type === 'date'" v-model="item[field.ref]" :label="field.label" inline />
|
||||
|
||||
<div v-if="!preferences.editorSimpleView" class="overflow-visible card bg-base-100 shadow-xl sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h3 class="text-lg font-medium leading-6">Sold Details</h3>
|
||||
</div>
|
||||
<div class="border-t border-gray-300 sm:p-0">
|
||||
<div v-for="field in soldFields" :key="field.ref" class="sm:divide-y sm:divide-gray-300 grid grid-cols-1">
|
||||
<div class="pt-2 pb-4 sm:px-6 border-b border-gray-300">
|
||||
<FormTextArea v-if="field.type === 'textarea'" v-model="item[field.ref]" :label="field.label" inline />
|
||||
<FormTextField
|
||||
v-else-if="field.type === 'text'"
|
||||
v-model="item[field.ref]"
|
||||
:label="field.label"
|
||||
inline
|
||||
/>
|
||||
<FormDatePicker
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="item[field.ref]"
|
||||
:label="field.label"
|
||||
inline
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseContainer>
|
||||
</template>
|
||||
|
|
|
@ -27,6 +27,7 @@
|
|||
"Model Number": item.value?.modelNumber || "",
|
||||
Manufacturer: item.value?.manufacturer || "",
|
||||
Notes: item.value?.notes || "",
|
||||
Insured: item.value?.insured ? "Yes" : "No",
|
||||
Attachments: "", // TODO: Attachments
|
||||
};
|
||||
});
|
||||
|
@ -35,15 +36,15 @@
|
|||
if (preferences.value.showEmpty) {
|
||||
return true;
|
||||
}
|
||||
return item.value?.warrantyExpires !== undefined;
|
||||
return validDate(item.value?.warrantyExpires);
|
||||
});
|
||||
|
||||
const warrantyDetails = computed(() => {
|
||||
const payload = {};
|
||||
const payload = {
|
||||
"Lifetime Warranty": item.value?.lifetimeWarranty ? "Yes" : "No",
|
||||
};
|
||||
|
||||
if (item.value.lifetimeWarranty) {
|
||||
payload["Lifetime Warranty"] = "Yes";
|
||||
} else {
|
||||
if (showWarranty.value) {
|
||||
payload["Warranty Expires"] = item.value?.warrantyExpires || "";
|
||||
}
|
||||
|
||||
|
@ -103,7 +104,7 @@
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<BaseContainer class="pb-8">
|
||||
<BaseContainer v-if="item" class="pb-8">
|
||||
<section class="px-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="form-control"></div>
|
||||
|
@ -116,11 +117,12 @@
|
|||
<span class="text-gray-600">
|
||||
{{ item.name }}
|
||||
</span>
|
||||
<p class="text-sm text-gray-600 font-bold pb-0 mb-0">Quantity {{ item.quantity }}</p>
|
||||
<template #after>
|
||||
<div class="flex flex-wrap gap-3 mt-3">
|
||||
<div v-if="item.labels && item.labels.length > 0" class="flex flex-wrap gap-3 mt-3">
|
||||
<LabelChip v-for="label in item.labels" :key="label.id" class="badge-primary" :label="label" />
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<div class="modal-action mt-3">
|
||||
<label class="label cursor-pointer mr-auto">
|
||||
<input v-model="preferences.showEmpty" type="checkbox" class="toggle toggle-primary" />
|
||||
<span class="label-text ml-4"> Show Empty </span>
|
||||
|
@ -166,12 +168,21 @@
|
|||
</BaseDetails>
|
||||
<BaseDetails v-if="showPurchase" :details="purchaseDetails">
|
||||
<template #title> Purchase Details </template>
|
||||
<template #PurchasedAt>
|
||||
<DateTime :date="item.purchaseTime" />
|
||||
</template>
|
||||
</BaseDetails>
|
||||
<BaseDetails v-if="showWarranty" :details="warrantyDetails">
|
||||
<template #title> Warranty </template>
|
||||
<template #WarrantyExpires>
|
||||
<DateTime :date="item.warrantyExpires" />
|
||||
</template>
|
||||
</BaseDetails>
|
||||
<BaseDetails v-if="showSold" :details="soldDetails">
|
||||
<template #title> Sold </template>
|
||||
<template #SoldAt>
|
||||
<DateTime :date="item.soldTime" />
|
||||
</template>
|
||||
</BaseDetails>
|
||||
</div>
|
||||
</section>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue