-
+
+
-
+
-
-
-
-
- {{ name }}
-
diff --git a/frontend/components/Item/AttachmentsList.vue b/frontend/components/Item/AttachmentsList.vue
index ddad600..69176ee 100644
--- a/frontend/components/Item/AttachmentsList.vue
+++ b/frontend/components/Item/AttachmentsList.vue
@@ -6,15 +6,15 @@
class="flex items-center justify-between py-3 pl-3 pr-4 text-sm"
>
-
+
{{ attachment.document.title }}
@@ -22,7 +22,10 @@
diff --git a/frontend/components/Location/Card.vue b/frontend/components/Location/Card.vue
index d0b6543..c29bed8 100644
--- a/frontend/components/Location/Card.vue
+++ b/frontend/components/Location/Card.vue
@@ -13,18 +13,13 @@
>
-
-
+
+
{{ location.name }}
-
+
{{ count }}
@@ -33,7 +28,9 @@
diff --git a/frontend/components/Location/Selector.vue b/frontend/components/Location/Selector.vue
index 82b7954..bcba455 100644
--- a/frontend/components/Location/Selector.vue
+++ b/frontend/components/Location/Selector.vue
@@ -8,7 +8,7 @@
v-if="selected"
:class="['absolute inset-y-0 right-0 flex items-center pr-4', active ? 'text-white' : 'text-primary']"
>
-
+
@@ -20,8 +20,10 @@
-
-
diff --git a/frontend/components/global/DateTime.vue b/frontend/components/global/DateTime.vue
index 9da127e..b23cac2 100644
--- a/frontend/components/global/DateTime.vue
+++ b/frontend/components/global/DateTime.vue
@@ -3,16 +3,18 @@
diff --git a/frontend/components/global/StatCard/StatCard.vue b/frontend/components/global/StatCard/StatCard.vue
index 37a386f..e4cf290 100644
--- a/frontend/components/global/StatCard/StatCard.vue
+++ b/frontend/components/global/StatCard/StatCard.vue
@@ -12,7 +12,7 @@
diff --git a/frontend/lib/api/__test__/factories/index.ts b/frontend/lib/api/__test__/factories/index.ts
index ff821f9..0cba850 100644
--- a/frontend/lib/api/__test__/factories/index.ts
+++ b/frontend/lib/api/__test__/factories/index.ts
@@ -2,7 +2,7 @@ import { faker } from "@faker-js/faker";
import { expect } from "vitest";
import { overrideParts } from "../../base/urls";
import { PublicApi } from "../../public";
-import { ItemField, LabelCreate, LocationCreate, UserRegistration } from "../../types/data-contracts";
+import type { ItemField, LabelCreate, LocationCreate, UserRegistration } from "../../types/data-contracts";
import * as config from "../../../../test/config";
import { UserClient } from "../../user";
import { Requests } from "../../../requests";
@@ -15,7 +15,7 @@ function itemField(id = null): ItemField {
type: "text",
textValue: faker.lorem.sentence(),
booleanValue: false,
- numberValue: faker.datatype.number(),
+ numberValue: faker.number.int(),
timeValue: "",
};
}
@@ -28,7 +28,7 @@ function user(): UserRegistration {
return {
email: faker.internet.email(),
password: faker.internet.password(),
- name: faker.name.firstName(),
+ name: faker.person.firstName(),
token: "",
};
}
@@ -36,7 +36,7 @@ function user(): UserRegistration {
function location(parentId: string | null = null): LocationCreate {
return {
parentId,
- name: faker.address.city(),
+ name: faker.location.city(),
description: faker.lorem.sentence(),
};
}
diff --git a/frontend/lib/api/__test__/test-utils.ts b/frontend/lib/api/__test__/test-utils.ts
index 41ef871..673773d 100644
--- a/frontend/lib/api/__test__/test-utils.ts
+++ b/frontend/lib/api/__test__/test-utils.ts
@@ -1,5 +1,6 @@
import { beforeAll, expect } from "vitest";
-import { UserClient } from "../user";
+import { faker } from "@faker-js/faker";
+import type { UserClient } from "../user";
import { factories } from "./factories";
const cache = {
@@ -15,9 +16,9 @@ export async function sharedUserClient(): Promise
{
return factories.client.user(cache.token);
}
const testUser = {
- email: "__test__@__test__.com",
- name: "__test__",
- password: "__test__",
+ email: faker.internet.email(),
+ name: faker.person.fullName(),
+ password: faker.internet.password(),
token: "",
};
diff --git a/frontend/lib/api/__test__/user/group.test.ts b/frontend/lib/api/__test__/user/group.test.ts
index 181cfd5..4ad82b2 100644
--- a/frontend/lib/api/__test__/user/group.test.ts
+++ b/frontend/lib/api/__test__/user/group.test.ts
@@ -2,13 +2,12 @@ import { faker } from "@faker-js/faker";
import { describe, test, expect } from "vitest";
import { factories } from "../factories";
import { sharedUserClient } from "../test-utils";
-import { currencies } from "~~/lib/data/currency";
describe("first time user workflow (register, login, join group)", () => {
test("user should be able to update group", async () => {
const { client } = await factories.client.singleUse();
- const name = faker.name.firstName();
+ const name = faker.person.firstName();
const { response, data: group } = await client.group.update({
name,
@@ -29,20 +28,6 @@ describe("first time user workflow (register, login, join group)", () => {
expect(group.currency).toBe("USD");
});
- test("currencies should be in sync with backend", async () => {
- const { client } = await factories.client.singleUse();
-
- for (const currency of currencies) {
- const { response, data: group } = await client.group.update({
- name: faker.name.firstName(),
- currency: currency.code,
- });
-
- expect(response.status).toBe(200);
- expect(group.currency).toBe(currency.code);
- }
- });
-
test("user should be able to join create join token and have user signup", async () => {
const api = factories.client.public();
diff --git a/frontend/lib/api/__test__/user/items.test.ts b/frontend/lib/api/__test__/user/items.test.ts
index 79c7f79..4f8973f 100644
--- a/frontend/lib/api/__test__/user/items.test.ts
+++ b/frontend/lib/api/__test__/user/items.test.ts
@@ -1,15 +1,15 @@
import { faker } from "@faker-js/faker";
import { describe, test, expect } from "vitest";
-import { ItemField, ItemUpdate, LocationOut } from "../../types/data-contracts";
+import type { ItemField, ItemUpdate, LocationOut } from "../../types/data-contracts";
import { AttachmentTypes } from "../../types/non-generated";
-import { UserClient } from "../../user";
+import type { UserClient } from "../../user";
import { factories } from "../factories";
import { sharedUserClient } from "../test-utils";
describe("user should be able to create an item and add an attachment", () => {
let increment = 0;
/**
- * useLocatio sets up a location resource for testing, and returns a function
+ * useLocation sets up a location resource for testing, and returns a function
* that can be used to delete the location from the backend server.
*/
async function useLocation(api: UserClient): Promise<[LocationOut, () => Promise]> {
@@ -135,8 +135,9 @@ describe("user should be able to create an item and add an attachment", () => {
const { response, data } = await api.items.maintenance.create(item.id, {
name: faker.vehicle.model(),
description: faker.lorem.paragraph(1),
- date: faker.date.past(1),
- cost: faker.datatype.number(100).toString(),
+ completedDate: faker.date.past(),
+ scheduledDate: "null",
+ cost: faker.number.int(100).toString(),
});
expect(response.status).toBe(201);
@@ -154,4 +155,42 @@ describe("user should be able to create an item and add an attachment", () => {
cleanup();
});
+
+ test("full path of item should be retrievable", async () => {
+ const api = await sharedUserClient();
+ const [location, cleanup] = await useLocation(api);
+
+ const locations = [location.name, faker.animal.dog(), faker.animal.cat(), faker.animal.cow(), faker.animal.bear()];
+
+ let lastLocationId = location.id;
+ for (let i = 1; i < locations.length; i++) {
+ // Skip first one
+ const { response, data: loc } = await api.locations.create({
+ parentId: lastLocationId,
+ name: locations[i],
+ description: "",
+ });
+ expect(response.status).toBe(201);
+
+ lastLocationId = loc.id;
+ }
+
+ const { response, data: item } = await api.items.create({
+ name: faker.vehicle.model(),
+ labelIds: [],
+ description: faker.lorem.paragraph(1),
+ locationId: lastLocationId,
+ });
+ expect(response.status).toBe(201);
+
+ const { response: pathResponse, data: fullpath } = await api.items.fullpath(item.id);
+ expect(pathResponse.status).toBe(200);
+
+ const names = fullpath.map(p => p.name);
+
+ expect(names).toHaveLength(locations.length + 1);
+ expect(names).toEqual([...locations, item.name]);
+
+ cleanup();
+ });
});
diff --git a/frontend/lib/api/__test__/user/labels.test.ts b/frontend/lib/api/__test__/user/labels.test.ts
index 5564e6b..851f6b1 100644
--- a/frontend/lib/api/__test__/user/labels.test.ts
+++ b/frontend/lib/api/__test__/user/labels.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
-import { LabelOut } from "../../types/data-contracts";
-import { UserClient } from "../../user";
+import type { LabelOut } from "../../types/data-contracts";
+import type { UserClient } from "../../user";
import { factories } from "../factories";
import { sharedUserClient } from "../test-utils";
diff --git a/frontend/lib/api/__test__/user/locations.test.ts b/frontend/lib/api/__test__/user/locations.test.ts
index 3012faf..834a567 100644
--- a/frontend/lib/api/__test__/user/locations.test.ts
+++ b/frontend/lib/api/__test__/user/locations.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
-import { LocationOut } from "../../types/data-contracts";
-import { UserClient } from "../../user";
+import type { LocationOut } from "../../types/data-contracts";
+import type { UserClient } from "../../user";
import { factories } from "../factories";
import { sharedUserClient } from "../test-utils";
diff --git a/frontend/lib/api/__test__/user/notifier.test.ts b/frontend/lib/api/__test__/user/notifier.test.ts
new file mode 100644
index 0000000..664b85e
--- /dev/null
+++ b/frontend/lib/api/__test__/user/notifier.test.ts
@@ -0,0 +1,59 @@
+import { faker } from "@faker-js/faker";
+import { describe, expect, test } from "vitest";
+import { factories } from "../factories";
+
+describe("basic notifier workflows", () => {
+ test("user should be able to create, update, and delete a notifier", async () => {
+ const { client } = await factories.client.singleUse();
+
+ // Create Notifier
+ const result = await client.notifiers.create({
+ name: faker.word.words(2),
+ url: "discord://" + faker.string.alphanumeric(10),
+ isActive: true,
+ });
+
+ expect(result.error).toBeFalsy();
+ expect(result.status).toBe(201);
+ expect(result.data).toBeTruthy();
+
+ const notifier = result.data;
+
+ // Update Notifier with new URL
+ {
+ const updateData = {
+ name: faker.word.words(2),
+ url: "discord://" + faker.string.alphanumeric(10),
+ isActive: true,
+ };
+
+ const updateResult = await client.notifiers.update(notifier.id, updateData);
+ expect(updateResult.error).toBeFalsy();
+ expect(updateResult.status).toBe(200);
+ expect(updateResult.data).toBeTruthy();
+ expect(updateResult.data.name).not.toBe(notifier.name);
+ }
+
+ // Update Notifier with empty URL
+ {
+ const updateData = {
+ name: faker.word.words(2),
+ url: null,
+ isActive: true,
+ };
+
+ const updateResult = await client.notifiers.update(notifier.id, updateData);
+ expect(updateResult.error).toBeFalsy();
+ expect(updateResult.status).toBe(200);
+ expect(updateResult.data).toBeTruthy();
+ expect(updateResult.data.name).not.toBe(notifier.name);
+ }
+
+ // Delete Notifier
+ {
+ const deleteResult = await client.notifiers.delete(notifier.id);
+ expect(deleteResult.error).toBeFalsy();
+ expect(deleteResult.status).toBe(204);
+ }
+ });
+});
diff --git a/frontend/lib/api/__test__/user/stats.test.ts b/frontend/lib/api/__test__/user/stats.test.ts
index 719f14f..51f4e2e 100644
--- a/frontend/lib/api/__test__/user/stats.test.ts
+++ b/frontend/lib/api/__test__/user/stats.test.ts
@@ -1,30 +1,30 @@
import { faker } from "@faker-js/faker";
import { beforeAll, describe, expect, test } from "vitest";
-import { UserClient } from "../../user";
+import type { UserClient } from "../../user";
import { factories } from "../factories";
type ImportObj = {
- ImportRef: string;
- Location: string;
- Labels: string;
- Quantity: string;
- Name: string;
- Description: string;
- Insured: boolean;
- SerialNumber: string;
- ModelNumber: string;
- Manufacturer: string;
- Notes: string;
- PurchaseFrom: string;
- PurchasedPrice: number;
- PurchasedTime: string;
- LifetimeWarranty: boolean;
- WarrantyExpires: string;
- WarrantyDetails: string;
- SoldTo: string;
- SoldPrice: number;
- SoldTime: string;
- SoldNotes: string;
+ [`HB.import_ref`]: string;
+ [`HB.location`]: string;
+ [`HB.labels`]: string;
+ [`HB.quantity`]: number;
+ [`HB.name`]: string;
+ [`HB.description`]: string;
+ [`HB.insured`]: boolean;
+ [`HB.serial_number`]: string;
+ [`HB.model_number`]: string;
+ [`HB.manufacturer`]: string;
+ [`HB.notes`]: string;
+ [`HB.purchase_price`]: number;
+ [`HB.purchase_from`]: string;
+ [`HB.purchase_time`]: string;
+ [`HB.lifetime_warranty`]: boolean;
+ [`HB.warranty_expires`]: string;
+ [`HB.warranty_details`]: string;
+ [`HB.sold_to`]: string;
+ [`HB.sold_price`]: number;
+ [`HB.sold_time`]: string;
+ [`HB.sold_notes`]: string;
};
function toCsv(data: ImportObj[]): string {
@@ -36,46 +36,50 @@ function toCsv(data: ImportObj[]): string {
}
function importFileGenerator(entries: number): ImportObj[] {
- const imports: ImportObj[] = [];
+ const imports: Partial[] = [];
const pick = (arr: string[]) => arr[Math.floor(Math.random() * arr.length)];
- const labels = faker.random.words(5).split(" ").join(";");
- const locations = faker.random.words(3).split(" ");
+ const labels = faker.word.words(5).split(" ").join(";");
+ const locations = faker.word.words(3).split(" ");
const half = Math.floor(entries / 2);
+ // YYYY-MM-DD
+ const formatDate = (date: Date) => date.toISOString().split("T")[0];
+
for (let i = 0; i < entries; i++) {
imports.push({
- ImportRef: faker.database.mongodbObjectId(),
- Location: pick(locations),
- Labels: labels,
- Quantity: faker.random.numeric(1),
- Name: faker.random.words(3),
- Description: "",
- Insured: faker.datatype.boolean(),
- SerialNumber: faker.random.alphaNumeric(5),
- ModelNumber: faker.random.alphaNumeric(5),
- Manufacturer: faker.random.alphaNumeric(5),
- Notes: "",
- PurchaseFrom: faker.name.fullName(),
- PurchasedPrice: faker.datatype.number(100),
- PurchasedTime: faker.date.past().toDateString(),
- LifetimeWarranty: half > i,
- WarrantyExpires: faker.date.future().toDateString(),
- WarrantyDetails: "",
- SoldTo: faker.name.fullName(),
- SoldPrice: faker.datatype.number(100),
- SoldTime: faker.date.past().toDateString(),
- SoldNotes: "",
+ [`HB.import_ref`]: faker.database.mongodbObjectId(),
+ [`HB.location`]: pick(locations),
+ [`HB.labels`]: labels,
+ [`HB.quantity`]: Number(faker.number.int(2)),
+ [`HB.name`]: faker.word.words(3),
+ [`HB.description`]: "",
+ [`HB.insured`]: faker.datatype.boolean(),
+ [`HB.serial_number`]: faker.string.alphanumeric(5),
+ [`HB.model_number`]: faker.string.alphanumeric(5),
+ [`HB.manufacturer`]: faker.string.alphanumeric(5),
+ [`HB.notes`]: "",
+ [`HB.purchase_from`]: faker.person.fullName(),
+ [`HB.purchase_price`]: faker.number.int(100),
+ [`HB.purchase_time`]: faker.date.past().toDateString(),
+ [`HB.lifetime_warranty`]: half > i,
+ [`HB.warranty_details`]: "",
+ [`HB.sold_to`]: faker.person.fullName(),
+ [`HB.sold_price`]: faker.number.int(100),
+ [`HB.sold_time`]: formatDate(faker.date.past()),
+ [`HB.sold_notes`]: "",
});
}
- return imports;
+ return imports as ImportObj[];
}
describe("group related statistics tests", () => {
const TOTAL_ITEMS = 30;
+ const labelData: Record = {};
+ const locationData: Record = {};
let tAPI: UserClient | undefined;
const imports = importFileGenerator(TOTAL_ITEMS);
@@ -97,10 +101,26 @@ describe("group related statistics tests", () => {
const setupResp = await client.items.import(new Blob([csv], { type: "text/csv" }));
expect(setupResp.status).toBe(204);
+
+ for (const item of imports) {
+ const labels = item[`HB.labels`].split(";");
+ for (const label of labels) {
+ if (labelData[label]) {
+ labelData[label] += item[`HB.purchase_price`];
+ } else {
+ labelData[label] = item[`HB.purchase_price`];
+ }
+ }
+
+ const location = item[`HB.location`];
+ if (locationData[location]) {
+ locationData[location] += item[`HB.purchase_price`];
+ } else {
+ locationData[location] = item[`HB.purchase_price`];
+ }
+ }
});
- // Write to file system for debugging
- // fs.writeFileSync("test.csv", csv);
test("Validate Group Statistics", async () => {
const { status, data } = await api().stats.group();
expect(status).toBe(200);
@@ -112,17 +132,6 @@ describe("group related statistics tests", () => {
expect(data.totalWithWarranty).toEqual(Math.floor(TOTAL_ITEMS / 2));
});
- const labelData: Record = {};
- const locationData: Record = {};
-
- for (const item of imports) {
- for (const label of item.Labels.split(";")) {
- labelData[label] = (labelData[label] || 0) + item.PurchasedPrice;
- }
-
- locationData[item.Location] = (locationData[item.Location] || 0) + item.PurchasedPrice;
- }
-
test("Validate Labels Statistics", async () => {
const { status, data } = await api().stats.labels();
expect(status).toBe(200);
diff --git a/frontend/lib/api/base/base-api.ts b/frontend/lib/api/base/base-api.ts
index 3847a4b..b9d01ae 100644
--- a/frontend/lib/api/base/base-api.ts
+++ b/frontend/lib/api/base/base-api.ts
@@ -1,4 +1,5 @@
-import { Requests } from "../../requests";
+import type { Requests } from "../../requests";
+import { route } from ".";
const ZERO_DATE = "0001-01-01T00:00:00Z";
@@ -70,12 +71,12 @@ export class BaseAPI {
this.attachmentToken = attachmentToken;
}
- // if a attachmentToken is present it will be added to URL as a query param
+ // if an attachmentToken is present, it will be added to URL as a query param
// this is done with a simple appending of the query param to the URL. If your
// URL already has a query param, this will not work.
authURL(url: string): string {
if (this.attachmentToken) {
- return `/api/v1${url}?access_token=${this.attachmentToken}`;
+ return route(url, { access_token: this.attachmentToken });
}
return url;
}
diff --git a/frontend/lib/api/base/urls.ts b/frontend/lib/api/base/urls.ts
index 31e263d..47a1c5b 100644
--- a/frontend/lib/api/base/urls.ts
+++ b/frontend/lib/api/base/urls.ts
@@ -11,13 +11,13 @@ export function overrideParts(host: string, prefix: string) {
export type QueryValue = string | string[] | number | number[] | boolean | null | undefined;
/**
- * route is a the main URL builder for the API. It will use a predefined host and prefix (global)
- * in the urls.ts file and then append the passed in path parameter uring the `URL` class from the
+ * route is the main URL builder for the API. It will use a predefined host and prefix (global)
+ * in the urls.ts file and then append the passed-in path parameter using the `URL` class from the
* browser. It will also append any query parameters passed in as the second parameter.
*
* The default host `http://localhost.com` is removed from the path if it is present. This allows us
* to bootstrap the API with different hosts as needed (like for testing) but still allows us to use
- * relative URLs in pruduction because the API and client bundle are served from the same server/host.
+ * relative URLs in production because the API and client bundle are served from the same server/host.
*/
export function route(rest: string, params: Record = {}): string {
const url = new URL(parts.prefix + rest, parts.host);
diff --git a/frontend/lib/api/classes/actions.ts b/frontend/lib/api/classes/actions.ts
index a65e059..3975a1d 100644
--- a/frontend/lib/api/classes/actions.ts
+++ b/frontend/lib/api/classes/actions.ts
@@ -1,5 +1,5 @@
import { BaseAPI, route } from "../base";
-import { ActionAmountResult } from "../types/data-contracts";
+import type { ActionAmountResult } from "../types/data-contracts";
export class ActionsAPI extends BaseAPI {
ensureAssetIDs() {
@@ -13,4 +13,16 @@ export class ActionsAPI extends BaseAPI {
url: route("/actions/zero-item-time-fields"),
});
}
+
+ ensureImportRefs() {
+ return this.http.post({
+ url: route("/actions/ensure-import-refs"),
+ });
+ }
+
+ setPrimaryPhotos() {
+ return this.http.post({
+ url: route("/actions/set-primary-photos"),
+ });
+ }
}
diff --git a/frontend/lib/api/classes/assets.ts b/frontend/lib/api/classes/assets.ts
index 8820bb3..c22d01b 100644
--- a/frontend/lib/api/classes/assets.ts
+++ b/frontend/lib/api/classes/assets.ts
@@ -1,11 +1,11 @@
import { BaseAPI, route } from "../base";
-import { ItemSummary } from "../types/data-contracts";
-import { PaginationResult } from "../types/non-generated";
+import type { ItemSummary } from "../types/data-contracts";
+import type { PaginationResult } from "../types/non-generated";
export class AssetsApi extends BaseAPI {
async get(id: string, page = 1, pageSize = 50) {
return await this.http.get>({
- url: route(`/asset/${id}`, { page, pageSize }),
+ url: route(`/assets/${id}`, { page, pageSize }),
});
}
}
diff --git a/frontend/lib/api/classes/group.ts b/frontend/lib/api/classes/group.ts
index 7468f09..a33dbf9 100644
--- a/frontend/lib/api/classes/group.ts
+++ b/frontend/lib/api/classes/group.ts
@@ -1,5 +1,11 @@
import { BaseAPI, route } from "../base";
-import { Group, GroupInvitation, GroupInvitationCreate, GroupUpdate } from "../types/data-contracts";
+import type {
+ CurrenciesCurrency,
+ Group,
+ GroupInvitation,
+ GroupInvitationCreate,
+ GroupUpdate,
+} from "../types/data-contracts";
export class GroupApi extends BaseAPI {
createInvitation(data: GroupInvitationCreate) {
@@ -21,4 +27,10 @@ export class GroupApi extends BaseAPI {
url: route("/groups"),
});
}
+
+ currencies() {
+ return this.http.get({
+ url: route("/currencies"),
+ });
+ }
}
diff --git a/frontend/lib/api/classes/items.ts b/frontend/lib/api/classes/items.ts
index 88df5ab..a5d3f2e 100644
--- a/frontend/lib/api/classes/items.ts
+++ b/frontend/lib/api/classes/items.ts
@@ -1,9 +1,11 @@
import { BaseAPI, route } from "../base";
import { parseDate } from "../base/base-api";
-import {
+import type {
ItemAttachmentUpdate,
ItemCreate,
ItemOut,
+ ItemPatch,
+ ItemPath,
ItemSummary,
ItemUpdate,
MaintenanceEntry,
@@ -11,24 +13,28 @@ import {
MaintenanceEntryUpdate,
MaintenanceLog,
} from "../types/data-contracts";
-import { AttachmentTypes, PaginationResult } from "../types/non-generated";
-import { Requests } from "~~/lib/requests";
+import type { AttachmentTypes, PaginationResult } from "../types/non-generated";
+import type { Requests } from "~~/lib/requests";
export type ItemsQuery = {
+ orderBy?: string;
includeArchived?: boolean;
page?: number;
pageSize?: number;
locations?: string[];
labels?: string[];
+ parentIds?: string[];
q?: string;
fields?: string[];
};
export class AttachmentsAPI extends BaseAPI {
- add(id: string, file: File | Blob, filename: string, type: AttachmentTypes) {
+ add(id: string, file: File | Blob, filename: string, type: AttachmentTypes | null = null) {
const formData = new FormData();
formData.append("file", file);
- formData.append("type", type);
+ if (type) {
+ formData.append("type", type);
+ }
formData.append("name", filename);
return this.http.post({
@@ -59,9 +65,14 @@ export class FieldsAPI extends BaseAPI {
}
}
+type MaintenanceEntryQuery = {
+ scheduled?: boolean;
+ completed?: boolean;
+};
+
export class MaintenanceAPI extends BaseAPI {
- getLog(itemId: string) {
- return this.http.get({ url: route(`/items/${itemId}/maintenance`) });
+ getLog(itemId: string, q: MaintenanceEntryQuery = {}) {
+ return this.http.get({ url: route(`/items/${itemId}/maintenance`, q) });
}
create(itemId: string, data: MaintenanceEntryCreate) {
@@ -95,6 +106,10 @@ export class ItemsApi extends BaseAPI {
this.maintenance = new MaintenanceAPI(http);
}
+ fullpath(id: string) {
+ return this.http.get({ url: route(`/items/${id}/path`) });
+ }
+
getAll(q: ItemsQuery = {}) {
return this.http.get>({ url: route("/items", q) });
}
@@ -132,6 +147,20 @@ export class ItemsApi extends BaseAPI {
return payload;
}
+ async patch(id: string, item: ItemPatch) {
+ const resp = await this.http.patch({
+ url: route(`/items/${id}`),
+ body: this.dropFields(item),
+ });
+
+ if (!resp.data) {
+ return resp;
+ }
+
+ resp.data = parseDate(resp.data, ["purchaseTime", "soldTime", "warrantyExpires"]);
+ return resp;
+ }
+
import(file: File | Blob) {
const formData = new FormData();
formData.append("csv", file);
diff --git a/frontend/lib/api/classes/labels.ts b/frontend/lib/api/classes/labels.ts
index 3a58bbe..6f7eaf0 100644
--- a/frontend/lib/api/classes/labels.ts
+++ b/frontend/lib/api/classes/labels.ts
@@ -1,10 +1,9 @@
import { BaseAPI, route } from "../base";
-import { LabelCreate, LabelOut } from "../types/data-contracts";
-import { Results } from "../types/non-generated";
+import type { LabelCreate, LabelOut } from "../types/data-contracts";
export class LabelsApi extends BaseAPI {
getAll() {
- return this.http.get>({ url: route("/labels") });
+ return this.http.get({ url: route("/labels") });
}
create(body: LabelCreate) {
diff --git a/frontend/lib/api/classes/locations.ts b/frontend/lib/api/classes/locations.ts
index a32b34d..0826611 100644
--- a/frontend/lib/api/classes/locations.ts
+++ b/frontend/lib/api/classes/locations.ts
@@ -1,6 +1,5 @@
import { BaseAPI, route } from "../base";
-import { LocationOutCount, LocationCreate, LocationOut, LocationUpdate, TreeItem } from "../types/data-contracts";
-import { Results } from "../types/non-generated";
+import type { LocationOutCount, LocationCreate, LocationOut, LocationUpdate, TreeItem } from "../types/data-contracts";
export type LocationsQuery = {
filterChildren: boolean;
@@ -12,11 +11,11 @@ export type TreeQuery = {
export class LocationsApi extends BaseAPI {
getAll(q: LocationsQuery = { filterChildren: false }) {
- return this.http.get>({ url: route("/locations", q) });
+ return this.http.get({ url: route("/locations", q) });
}
getTree(tq = { withItems: false }) {
- return this.http.get>({ url: route("/locations/tree", tq) });
+ return this.http.get({ url: route("/locations/tree", tq) });
}
create(body: LocationCreate) {
diff --git a/frontend/lib/api/classes/notifiers.ts b/frontend/lib/api/classes/notifiers.ts
new file mode 100644
index 0000000..37044c0
--- /dev/null
+++ b/frontend/lib/api/classes/notifiers.ts
@@ -0,0 +1,28 @@
+import { BaseAPI, route } from "../base";
+import type { NotifierCreate, NotifierOut, NotifierUpdate } from "../types/data-contracts";
+
+export class NotifiersAPI extends BaseAPI {
+ getAll() {
+ return this.http.get({ url: route("/notifiers") });
+ }
+
+ create(body: NotifierCreate) {
+ return this.http.post({ url: route("/notifiers"), body });
+ }
+
+ update(id: string, body: NotifierUpdate) {
+ if (body.url === "") {
+ body.url = null;
+ }
+
+ return this.http.put({ url: route(`/notifiers/${id}`), body });
+ }
+
+ delete(id: string) {
+ return this.http.delete({ url: route(`/notifiers/${id}`) });
+ }
+
+ test(url: string) {
+ return this.http.post<{ url: string }, null>({ url: route(`/notifiers/test`), body: { url } });
+ }
+}
diff --git a/frontend/lib/api/classes/stats.ts b/frontend/lib/api/classes/stats.ts
index 7959269..f6e5cec 100644
--- a/frontend/lib/api/classes/stats.ts
+++ b/frontend/lib/api/classes/stats.ts
@@ -1,5 +1,5 @@
import { BaseAPI, route } from "../base";
-import { GroupStatistics, TotalsByOrganizer, ValueOverTime } from "../types/data-contracts";
+import type { GroupStatistics, TotalsByOrganizer, ValueOverTime } from "../types/data-contracts";
function YYYY_MM_DD(date?: Date): string {
if (!date) {
diff --git a/frontend/lib/api/classes/users.ts b/frontend/lib/api/classes/users.ts
index 21006d0..292fa7e 100644
--- a/frontend/lib/api/classes/users.ts
+++ b/frontend/lib/api/classes/users.ts
@@ -1,6 +1,6 @@
import { BaseAPI, route } from "../base";
-import { ChangePassword, UserOut } from "../types/data-contracts";
-import { Result } from "../types/non-generated";
+import type { ChangePassword, UserOut } from "../types/data-contracts";
+import type { Result } from "../types/non-generated";
export class UserApi extends BaseAPI {
public self() {
diff --git a/frontend/lib/api/public.ts b/frontend/lib/api/public.ts
index 6fb309a..513e492 100644
--- a/frontend/lib/api/public.ts
+++ b/frontend/lib/api/public.ts
@@ -1,10 +1,5 @@
import { BaseAPI, route } from "./base";
-import { ApiSummary, TokenResponse, UserRegistration } from "./types/data-contracts";
-
-export type LoginPayload = {
- username: string;
- password: string;
-};
+import type { APISummary, LoginForm, TokenResponse, UserRegistration } from "./types/data-contracts";
export type StatusResult = {
health: boolean;
@@ -15,15 +10,16 @@ export type StatusResult = {
export class PublicApi extends BaseAPI {
public status() {
- return this.http.get({ url: route("/status") });
+ return this.http.get({ url: route("/status") });
}
- public login(username: string, password: string) {
- return this.http.post({
+ public login(username: string, password: string, stayLoggedIn = false) {
+ return this.http.post({
url: route("/users/login"),
body: {
username,
password,
+ stayLoggedIn,
},
});
}
diff --git a/frontend/lib/api/types/data-contracts.ts b/frontend/lib/api/types/data-contracts.ts
index d8ce947..384ffb6 100644
--- a/frontend/lib/api/types/data-contracts.ts
+++ b/frontend/lib/api/types/data-contracts.ts
@@ -10,6 +10,13 @@
* ---------------------------------------------------------------
*/
+export interface CurrenciesCurrency {
+ code: string;
+ local: string;
+ name: string;
+ symbol: string;
+}
+
export interface DocumentOut {
id: string;
path: string;
@@ -42,22 +49,29 @@ export interface ItemAttachment {
createdAt: Date | string;
document: DocumentOut;
id: string;
+ primary: boolean;
type: string;
updatedAt: Date | string;
}
export interface ItemAttachmentUpdate {
+ primary: boolean;
title: string;
type: string;
}
export interface ItemCreate {
+ /** @maxLength 1000 */
description: string;
labelIds: string[];
/** Edges */
locationId: string;
+ /**
+ * @minLength 1
+ * @maxLength 255
+ */
name: string;
- parentId: string | null;
+ parentId?: string | null;
}
export interface ItemField {
@@ -74,23 +88,23 @@ export interface ItemOut {
/** @example "0" */
assetId: string;
attachments: ItemAttachment[];
- children: ItemSummary[];
createdAt: Date | string;
description: string;
fields: ItemField[];
id: string;
+ imageId: string;
insured: boolean;
labels: LabelSummary[];
/** Warranty */
lifetimeWarranty: boolean;
/** Edges */
- location: LocationSummary | null;
+ location?: LocationSummary | null;
manufacturer: string;
modelNumber: string;
name: string;
/** Extras */
notes: string;
- parent: ItemSummary | null;
+ parent?: ItemSummary | null;
purchaseFrom: string;
/** @example "0" */
purchasePrice: string;
@@ -109,15 +123,27 @@ export interface ItemOut {
warrantyExpires: Date | string;
}
+export interface ItemPatch {
+ id: string;
+ quantity?: number | null;
+}
+
+export interface ItemPath {
+ id: string;
+ name: string;
+ type: ItemType;
+}
+
export interface ItemSummary {
archived: boolean;
createdAt: Date | string;
description: string;
id: string;
+ imageId: string;
insured: boolean;
labels: LabelSummary[];
/** Edges */
- location: LocationSummary | null;
+ location?: LocationSummary | null;
name: string;
/** @example "0" */
purchasePrice: string;
@@ -125,6 +151,11 @@ export interface ItemSummary {
updatedAt: Date | string;
}
+export enum ItemType {
+ ItemTypeLocation = "location",
+ ItemTypeItem = "item",
+}
+
export interface ItemUpdate {
archived: boolean;
assetId: string;
@@ -142,7 +173,7 @@ export interface ItemUpdate {
name: string;
/** Extras */
notes: string;
- parentId: string | null;
+ parentId?: string | null;
purchaseFrom: string;
/** @example "0" */
purchasePrice: string;
@@ -158,13 +189,17 @@ export interface ItemUpdate {
soldTime: Date | string;
soldTo: string;
warrantyDetails: string;
- /** Sold */
warrantyExpires: Date | string;
}
export interface LabelCreate {
color: string;
+ /** @maxLength 255 */
description: string;
+ /**
+ * @minLength 1
+ * @maxLength 255
+ */
name: string;
}
@@ -172,7 +207,6 @@ export interface LabelOut {
createdAt: Date | string;
description: string;
id: string;
- items: ItemSummary[];
name: string;
updatedAt: Date | string;
}
@@ -188,7 +222,7 @@ export interface LabelSummary {
export interface LocationCreate {
description: string;
name: string;
- parentId: string | null;
+ parentId?: string | null;
}
export interface LocationOut {
@@ -196,7 +230,6 @@ export interface LocationOut {
createdAt: Date | string;
description: string;
id: string;
- items: ItemSummary[];
name: string;
parent: LocationSummary;
updatedAt: Date | string;
@@ -223,32 +256,35 @@ export interface LocationUpdate {
description: string;
id: string;
name: string;
- parentId: string | null;
+ parentId?: string | null;
}
export interface MaintenanceEntry {
+ completedDate: Date | string;
/** @example "0" */
cost: string;
- date: Date | string;
description: string;
id: string;
name: string;
+ scheduledDate: Date | string;
}
export interface MaintenanceEntryCreate {
+ completedDate: Date | string;
/** @example "0" */
cost: string;
- date: Date | string;
description: string;
name: string;
+ scheduledDate: Date | string;
}
export interface MaintenanceEntryUpdate {
+ completedDate: Date | string;
/** @example "0" */
cost: string;
- date: Date | string;
description: string;
name: string;
+ scheduledDate: Date | string;
}
export interface MaintenanceLog {
@@ -258,6 +294,36 @@ export interface MaintenanceLog {
itemId: string;
}
+export interface NotifierCreate {
+ isActive: boolean;
+ /**
+ * @minLength 1
+ * @maxLength 255
+ */
+ name: string;
+ url: string;
+}
+
+export interface NotifierOut {
+ createdAt: Date | string;
+ groupId: string;
+ id: string;
+ isActive: boolean;
+ name: string;
+ updatedAt: Date | string;
+ userId: string;
+}
+
+export interface NotifierUpdate {
+ isActive: boolean;
+ /**
+ * @minLength 1
+ * @maxLength 255
+ */
+ name: string;
+ url?: string | null;
+}
+
export interface PaginationResultItemSummary {
items: ItemSummary[];
page: number;
@@ -307,22 +373,6 @@ export interface ValueOverTimeEntry {
value: number;
}
-export interface ServerErrorResponse {
- error: string;
- fields: Record;
-}
-
-export interface ServerResult {
- details: any;
- error: boolean;
- item: any;
- message: string;
-}
-
-export interface ServerResults {
- items: any;
-}
-
export interface UserRegistration {
email: string;
name: string;
@@ -330,11 +380,7 @@ export interface UserRegistration {
token: string;
}
-export interface ActionAmountResult {
- completed: number;
-}
-
-export interface ApiSummary {
+export interface APISummary {
allowRegistration: boolean;
build: Build;
demo: boolean;
@@ -344,6 +390,10 @@ export interface ApiSummary {
versions: string[];
}
+export interface ActionAmountResult {
+ completed: number;
+}
+
export interface Build {
buildTime: string;
commit: string;
@@ -363,6 +413,10 @@ export interface GroupInvitation {
export interface GroupInvitationCreate {
expiresAt: Date | string;
+ /**
+ * @min 1
+ * @max 100
+ */
uses: number;
}
@@ -370,8 +424,23 @@ export interface ItemAttachmentToken {
token: string;
}
+export interface LoginForm {
+ password: string;
+ stayLoggedIn: boolean;
+ username: string;
+}
+
export interface TokenResponse {
attachmentToken: string;
expiresAt: Date | string;
token: string;
}
+
+export interface Wrapped {
+ item: any;
+}
+
+export interface ValidateErrorResponse {
+ error: string;
+ fields: string;
+}
diff --git a/frontend/lib/api/types/non-generated.ts b/frontend/lib/api/types/non-generated.ts
index acb7e01..bc920de 100644
--- a/frontend/lib/api/types/non-generated.ts
+++ b/frontend/lib/api/types/non-generated.ts
@@ -10,10 +10,6 @@ export type Result = {
item: T;
};
-export type Results = {
- items: T[];
-};
-
export interface PaginationResult {
items: T[];
page: number;
diff --git a/frontend/lib/api/user.ts b/frontend/lib/api/user.ts
index fa08258..d477d48 100644
--- a/frontend/lib/api/user.ts
+++ b/frontend/lib/api/user.ts
@@ -8,7 +8,8 @@ import { ActionsAPI } from "./classes/actions";
import { StatsAPI } from "./classes/stats";
import { AssetsApi } from "./classes/assets";
import { ReportsAPI } from "./classes/reports";
-import { Requests } from "~~/lib/requests";
+import { NotifiersAPI } from "./classes/notifiers";
+import type { Requests } from "~~/lib/requests";
export class UserClient extends BaseAPI {
locations: LocationsApi;
@@ -20,6 +21,7 @@ export class UserClient extends BaseAPI {
stats: StatsAPI;
assets: AssetsApi;
reports: ReportsAPI;
+ notifiers: NotifiersAPI;
constructor(requests: Requests, attachmentToken: string) {
super(requests, attachmentToken);
@@ -33,22 +35,8 @@ export class UserClient extends BaseAPI {
this.stats = new StatsAPI(requests);
this.assets = new AssetsApi(requests);
this.reports = new ReportsAPI(requests);
+ this.notifiers = new NotifiersAPI(requests);
Object.freeze(this);
}
-
- /** @deprecated use this.user.self() */
- public self() {
- return this.user.self();
- }
-
- /** @deprecated use this.user.logout() */
- public logout() {
- return this.user.logout();
- }
-
- /** @deprecated use this.user.delete() */
- public deleteAccount() {
- return this.user.delete();
- }
}
diff --git a/frontend/lib/data/currency.ts b/frontend/lib/data/currency.ts
deleted file mode 100644
index e49ec09..0000000
--- a/frontend/lib/data/currency.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-export type Codes = "USD" | "EUR" | "GBP" | "JPY" | "ZAR" | "AUD" | "NOK" | "SEK" | "DKK" | "INR" | "RMB" | "BGN";
-
-export type Currency = {
- code: Codes;
- local: string;
- symbol: string;
- name: string;
-};
-
-export const currencies: Currency[] = [
- {
- code: "AUD",
- local: "en-AU",
- symbol: "$",
- name: "Australian Dollar",
- },
- {
- code: "GBP",
- local: "en-GB",
- symbol: "£",
- name: "British Pound",
- },
- {
- code: "RMB",
- local: "zh-CN",
- symbol: "¥",
- name: "Chinese Yuan",
- },
- {
- code: "DKK",
- local: "da-DK",
- symbol: "kr",
- name: "Danish Krone",
- },
- {
- code: "EUR",
- local: "de-DE",
- symbol: "€",
- name: "Euro",
- },
- {
- code: "INR",
- local: "en-IN",
- symbol: "₹",
- name: "Indian Rupee",
- },
- {
- code: "JPY",
- local: "ja-JP",
- symbol: "¥",
- name: "Japanese Yen",
- },
- {
- code: "NOK",
- local: "nb-NO",
- symbol: "kr",
- name: "Norwegian Krone",
- },
- {
- code: "ZAR",
- local: "en-ZA",
- symbol: "R",
- name: "South African Rand",
- },
- {
- code: "SEK",
- local: "sv-SE",
- symbol: "kr",
- name: "Swedish Krona",
- },
- {
- code: "USD",
- local: "en-US",
- symbol: "$",
- name: "US Dollar",
- },
- {
- code: "BGN",
- local: "bg-BG",
- symbol: "lv",
- name: "Bulgarian lev",
- },
-];
diff --git a/frontend/lib/datelib/datelib.test.ts b/frontend/lib/datelib/datelib.test.ts
new file mode 100644
index 0000000..171d2cc
--- /dev/null
+++ b/frontend/lib/datelib/datelib.test.ts
@@ -0,0 +1,43 @@
+import { describe, test, expect } from "vitest";
+import { format, zeroTime, factorRange, parse } from "./datelib";
+
+describe("format", () => {
+ test("should format a date as a string", () => {
+ const date = new Date(2020, 1, 1);
+ expect(format(date)).toBe("2020-02-01");
+ });
+
+ test("should return the string if a string is passed in", () => {
+ expect(format("2020-02-01")).toBe("2020-02-01");
+ });
+});
+
+describe("zeroTime", () => {
+ test("should zero out the time", () => {
+ const date = new Date(2020, 1, 1, 12, 30, 30);
+ const zeroed = zeroTime(date);
+ expect(zeroed.getHours()).toBe(0);
+ expect(zeroed.getMinutes()).toBe(0);
+ expect(zeroed.getSeconds()).toBe(0);
+ });
+});
+
+describe("factorRange", () => {
+ test("should return a range of dates", () => {
+ const [start, end] = factorRange(10);
+ // Start should be today
+ expect(start).toBeInstanceOf(Date);
+ expect(start.getFullYear()).toBe(new Date().getFullYear());
+
+ // End should be 10 days from now
+ expect(end).toBeInstanceOf(Date);
+ expect(end.getFullYear()).toBe(new Date().getFullYear());
+ });
+});
+
+describe("parse", () => {
+ test("should parse a date string", () => {
+ const date = parse("2020-02-01");
+ expect(date).toBeInstanceOf(Date);
+ });
+});
diff --git a/frontend/lib/datelib/datelib.ts b/frontend/lib/datelib/datelib.ts
new file mode 100644
index 0000000..c70dbf9
--- /dev/null
+++ b/frontend/lib/datelib/datelib.ts
@@ -0,0 +1,34 @@
+import { addDays } from "date-fns";
+
+/*
+ * Formats a date as a string
+ * */
+export function format(date: Date | string): string {
+ if (typeof date === "string") {
+ return date;
+ }
+ return date.toISOString().split("T")[0];
+}
+
+export function zeroTime(date: Date): Date {
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
+}
+
+export function factorRange(offset: number = 7): [Date, Date] {
+ const date = zeroTime(new Date());
+
+ return [date, addDays(date, offset)];
+}
+
+export function factory(offset = 0): Date {
+ if (offset) {
+ return addDays(zeroTime(new Date()), offset);
+ }
+
+ return zeroTime(new Date());
+}
+
+export function parse(yyyyMMdd: string): Date {
+ const parts = yyyyMMdd.split("-");
+ return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
+}
diff --git a/frontend/lib/requests/requests.ts b/frontend/lib/requests/requests.ts
index 32b79bc..8aecda1 100644
--- a/frontend/lib/requests/requests.ts
+++ b/frontend/lib/requests/requests.ts
@@ -3,6 +3,7 @@ export enum Method {
POST = "POST",
PUT = "PUT",
DELETE = "DELETE",
+ PATCH = "PATCH",
}
export type ResponseInterceptor = (r: Response, rq?: RequestInit) => void;
@@ -57,12 +58,16 @@ export class Requests {
return this.do(Method.PUT, args);
}
+ public patch(args: RequestArgs): Promise> {
+ return this.do(Method.PATCH, args);
+ }
+
public delete(args: RequestArgs): Promise> {
return this.do(Method.DELETE, args);
}
private methodSupportsBody(method: Method): boolean {
- return method === Method.POST || method === Method.PUT;
+ return method === Method.POST || method === Method.PUT || method === Method.PATCH;
}
private async do(method: Method, rargs: RequestArgs): Promise> {
diff --git a/frontend/middleware/auth.ts b/frontend/middleware/auth.ts
index 1c9e95d..97a9920 100644
--- a/frontend/middleware/auth.ts
+++ b/frontend/middleware/auth.ts
@@ -2,10 +2,21 @@ export default defineNuxtRouteMiddleware(async () => {
const ctx = useAuthContext();
const api = useUserApi();
+ if (!ctx.isAuthorized()) {
+ if (window.location.pathname !== "/") {
+ console.debug("[middleware/auth] isAuthorized returned false, redirecting to /");
+ return navigateTo("/");
+ }
+ }
+
if (!ctx.user) {
+ console.log("Fetching user data");
const { data, error } = await api.user.self();
if (error) {
- navigateTo("/");
+ if (window.location.pathname !== "/") {
+ console.debug("[middleware/user] user is null and fetch failed, redirecting to /");
+ return navigateTo("/");
+ }
}
ctx.user = data.item;
diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts
index 482fc05..dc45baa 100644
--- a/frontend/nuxt.config.ts
+++ b/frontend/nuxt.config.ts
@@ -3,14 +3,60 @@ import { defineNuxtConfig } from "nuxt/config";
// https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({
ssr: false,
- modules: ["@nuxtjs/tailwindcss", "@pinia/nuxt", "@vueuse/nuxt"],
+ modules: [
+ "@nuxtjs/tailwindcss",
+ "@pinia/nuxt",
+ "@vueuse/nuxt",
+ "@vite-pwa/nuxt",
+ "./nuxt.proxyoverride.ts",
+ "unplugin-icons/nuxt",
+ ],
nitro: {
devProxy: {
"/api": {
target: "http://localhost:7745/api",
+ ws: true,
changeOrigin: true,
},
},
},
css: ["@/assets/css/main.css"],
+ pwa: {
+ workbox: {
+ navigateFallbackDenylist: [/^\/api/],
+ },
+ injectRegister: "script",
+ injectManifest: {
+ swSrc: "sw.js",
+ },
+ devOptions: {
+ // Enable to troubleshoot during development
+ enabled: false,
+ },
+ manifest: {
+ name: "Homebox",
+ short_name: "Homebox",
+ description: "Home Inventory App",
+ theme_color: "#5b7f67",
+ start_url: "/home",
+ icons: [
+ {
+ src: "pwa-192x192.png",
+ sizes: "192x192",
+ type: "image/png",
+ },
+ {
+ src: "pwa-512x512.png",
+ sizes: "512x512",
+ type: "image/png",
+ },
+ {
+ src: "pwa-512x512.png",
+ sizes: "512x512",
+ type: "image/png",
+ purpose: "any maskable",
+ },
+ ],
+ },
+ },
});
diff --git a/frontend/nuxt.proxyoverride.ts b/frontend/nuxt.proxyoverride.ts
new file mode 100644
index 0000000..8650dd6
--- /dev/null
+++ b/frontend/nuxt.proxyoverride.ts
@@ -0,0 +1,52 @@
+// https://gist.github.com/ucw/67f7291c64777fb24341e8eae72bcd24
+import type { IncomingMessage } from "http";
+import type internal from "stream";
+import { defineNuxtModule, logger } from "@nuxt/kit";
+// Related To
+// - https://github.com/nuxt/nuxt/issues/15417
+// - https://github.com/nuxt/cli/issues/107
+//
+// fix from
+// - https://gist.github.com/ucw/67f7291c64777fb24341e8eae72bcd24
+// eslint-disable-next-line
+import { createProxyServer } from "http-proxy";
+
+export default defineNuxtModule({
+ defaults: {
+ target: "ws://localhost:7745",
+ path: "/api/v1/ws",
+ },
+ meta: {
+ configKey: "websocketProxy",
+ name: "Websocket proxy",
+ },
+ setup(resolvedOptions, nuxt) {
+ if (!nuxt.options.dev || !resolvedOptions.target) {
+ return;
+ }
+
+ nuxt.hook("listen", server => {
+ const proxy = createProxyServer({
+ ws: true,
+ secure: false,
+ changeOrigin: true,
+ target: resolvedOptions.target,
+ });
+
+ const proxyFn = (req: IncomingMessage, socket: internal.Duplex, head: Buffer) => {
+ if (req.url && req.url.startsWith(resolvedOptions.path)) {
+ proxy.ws(req, socket, head);
+ }
+ };
+
+ server.on("upgrade", proxyFn);
+
+ nuxt.hook("close", () => {
+ server.off("upgrade", proxyFn);
+ proxy.close();
+ });
+
+ logger.info(`Websocket dev proxy started on ${resolvedOptions.path}`);
+ });
+ },
+});
diff --git a/frontend/package.json b/frontend/package.json
index 8b147d1..f1a05ed 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,43 +14,50 @@
"test:watch": " TEST_SHUTDOWN_API_SERVER=false vitest --config ./test/vitest.config.ts"
},
"devDependencies": {
- "@faker-js/faker": "^7.5.0",
- "@nuxtjs/eslint-config-typescript": "^12.0.0",
- "@types/dompurify": "^2.4.0",
- "@types/markdown-it": "^12.2.3",
- "@typescript-eslint/eslint-plugin": "^5.36.2",
- "@typescript-eslint/parser": "^5.36.2",
- "eslint": "^8.23.0",
- "eslint-config-prettier": "^8.5.0",
- "eslint-plugin-prettier": "^4.2.1",
- "eslint-plugin-vue": "^9.4.0",
+ "@faker-js/faker": "^8.4.1",
+ "@iconify-json/mdi": "^1.2.3",
+ "@nuxtjs/eslint-config-typescript": "^12.1.0",
+ "@types/dompurify": "^3.2.0",
+ "@types/markdown-it": "^13.0.9",
+ "@typescript-eslint/eslint-plugin": "^6.21.0",
+ "@typescript-eslint/parser": "^6.21.0",
+ "@vite-pwa/nuxt": "^0.5.0",
+ "eslint": "^8.57.1",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-plugin-prettier": "^5.2.6",
+ "eslint-plugin-vue": "^9.33.0",
+ "h3": "^1.7.1",
"isomorphic-fetch": "^3.0.0",
- "nuxt": "3.2.2",
- "prettier": "^2.7.1",
- "typescript": "^4.8.3",
+ "nuxt": "3.6.5",
+ "prettier": "^3.5.3",
+ "typescript": "^5.8.3",
+ "unplugin-icons": "^0.18.5",
"vite-plugin-eslint": "^1.8.1",
- "vitest": "^0.28.0"
+ "vitest": "^1.6.1"
},
"dependencies": {
- "@headlessui/vue": "^1.7.9",
- "@iconify/vue": "^3.2.1",
- "@nuxtjs/tailwindcss": "^6.1.3",
- "@pinia/nuxt": "^0.4.1",
- "@tailwindcss/aspect-ratio": "^0.4.0",
- "@tailwindcss/forms": "^0.5.2",
- "@tailwindcss/typography": "^0.5.4",
- "@vueuse/nuxt": "^9.1.1",
- "@vueuse/router": "^9.9.0",
- "autoprefixer": "^10.4.8",
- "chart.js": "^4.0.1",
- "daisyui": "^2.24.0",
- "dompurify": "^3.0.0",
- "markdown-it": "^13.0.1",
- "pinia": "^2.0.21",
- "postcss": "^8.4.16",
- "tailwindcss": "^3.1.8",
- "vue": "^3.2.45",
- "vue-chartjs": "^4.1.2",
- "vue-router": "4"
+ "@headlessui/vue": "^1.7.23",
+ "@nuxtjs/tailwindcss": "^6.13.2",
+ "@pinia/nuxt": "^0.5.5",
+ "@tailwindcss/aspect-ratio": "^0.4.2",
+ "@tailwindcss/forms": "^0.5.10",
+ "@tailwindcss/typography": "^0.5.16",
+ "@types/lunr": "^2.3.7",
+ "@vuepic/vue-datepicker": "^8.8.1",
+ "@vueuse/nuxt": "^10.11.1",
+ "@vueuse/router": "^10.11.1",
+ "autoprefixer": "^10.4.21",
+ "daisyui": "^2.52.0",
+ "date-fns": "^3.6.0",
+ "dompurify": "^3.2.5",
+ "h3": "^1.15.1",
+ "http-proxy": "^1.18.1",
+ "lunr": "^2.3.9",
+ "markdown-it": "^14.1.0",
+ "pinia": "^2.3.1",
+ "postcss": "^8.5.3",
+ "tailwindcss": "^3.4.17",
+ "vue": "3.4.8",
+ "vue-router": "^4.5.0"
}
-}
\ No newline at end of file
+}
diff --git a/frontend/pages/home/charts.ts b/frontend/pages/home/charts.ts
deleted file mode 100644
index ff9c8c7..0000000
--- a/frontend/pages/home/charts.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import { TChartData } from "vue-chartjs/dist/types";
-import { UserClient } from "~~/lib/api/user";
-
-export function purchasePriceOverTimeChart(api: UserClient) {
- const { data: timeseries } = useAsyncData(async () => {
- const { data } = await api.stats.totalPriceOverTime();
- return data;
- });
-
- const primary = useCssVar("--p");
-
- return computed(() => {
- if (!timeseries.value) {
- return {
- labels: ["Purchase Price"],
- datasets: [
- {
- label: "Purchase Price",
- data: [],
- backgroundColor: primary.value,
- borderColor: primary.value,
- },
- ],
- } as TChartData<"line", number[], unknown>;
- }
-
- let start = timeseries.value?.valueAtStart;
-
- return {
- labels: timeseries?.value.entries.map(t => new Date(t.date).toDateString()) || [],
- datasets: [
- {
- label: "Purchase Price",
- data:
- timeseries.value?.entries.map(t => {
- start += t.value;
- return start;
- }) || [],
- backgroundColor: primary.value,
- borderColor: primary.value,
- },
- ],
- } as TChartData<"line", number[], unknown>;
- });
-}
-
-export function inventoryByLocationChart(api: UserClient) {
- const { data: donutSeries } = useAsyncData(async () => {
- const { data } = await api.stats.locations();
- return data;
- });
-
- const primary = useCssVar("--p");
- const secondary = useCssVar("--s");
- const neutral = useCssVar("--n");
-
- return computed(() => {
- return {
- labels: donutSeries.value?.map(l => l.name) || [],
- datasets: [
- {
- label: "Value",
- data: donutSeries.value?.map(l => l.total) || [],
- backgroundColor: [primary.value, secondary.value, neutral.value],
- borderColor: [primary.value, secondary.value, neutral.value],
- hoverOffset: 4,
- },
- ],
- };
- });
-}
diff --git a/frontend/pages/home/index.vue b/frontend/pages/home/index.vue
index 5ce3f0d..c114eee 100644
--- a/frontend/pages/home/index.vue
+++ b/frontend/pages/home/index.vue
@@ -22,55 +22,11 @@
const itemTable = itemsTable(api);
const stats = statCardData(api);
-
- // const purchasePriceOverTime = purchasePriceOverTimeChart(api);
-
- // const inventoryByLocation = inventoryByLocationChart(api);
-
- // const refDonutEl = ref();
-
- // const donutElWidth = computed(() => {
- // return refDonutEl.value?.clientWidth || 0;
- // });
-
-
Quick Statistics
diff --git a/frontend/pages/home/statistics.ts b/frontend/pages/home/statistics.ts
index e1c7bf1..40ec1c2 100644
--- a/frontend/pages/home/statistics.ts
+++ b/frontend/pages/home/statistics.ts
@@ -1,4 +1,4 @@
-import { UserClient } from "~~/lib/api/user";
+import type { UserClient } from "~~/lib/api/user";
type StatCard = {
label: string;
diff --git a/frontend/pages/home/table.ts b/frontend/pages/home/table.ts
index 127ecbb..eaff6da 100644
--- a/frontend/pages/home/table.ts
+++ b/frontend/pages/home/table.ts
@@ -1,14 +1,20 @@
-import { UserClient } from "~~/lib/api/user";
+import type { UserClient } from "~~/lib/api/user";
export function itemsTable(api: UserClient) {
- const { data: items } = useAsyncData(async () => {
+ const { data: items, refresh } = useAsyncData(async () => {
const { data } = await api.items.getAll({
page: 1,
pageSize: 5,
+ orderBy: "createdAt",
});
return data.items;
});
+ onServerEvent(ServerEvent.ItemMutation, () => {
+ console.log("item mutation");
+ refresh();
+ });
+
return computed(() => {
return {
items: items.value || [],
diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue
index 5bcc8c5..2f761e2 100644
--- a/frontend/pages/index.vue
+++ b/frontend/pages/index.vue
@@ -1,10 +1,28 @@
-
+
x
-
Track, Organize, and Manage your Shit.
+
Track, Organize, and Manage your Things.
@@ -156,7 +171,7 @@
-
+
Register
@@ -186,7 +201,7 @@
-
+
Login
@@ -196,8 +211,16 @@
-
-
+
+
+
+
+
Login
@@ -212,21 +235,21 @@
@click="() => toggleLogin()"
>
-
-
-
+
+
+
{{ registerForm ? "Login" : "Register" }}
-
+
Registration Disabled
-
+
Version: {{ status.build.version }} ~ Build: {{ status.build.commit }}
diff --git a/frontend/pages/item/[id]/index.vue b/frontend/pages/item/[id]/index.vue
index e0bac4e..c96f7ab 100644
--- a/frontend/pages/item/[id]/index.vue
+++ b/frontend/pages/item/[id]/index.vue
@@ -1,6 +1,12 @@
@@ -379,10 +445,10 @@
@@ -391,33 +457,45 @@
-
-
-
- {{ item ? item.name : "" }}
-
-
-
-
-
- {{ item.parent.name }}
-
- {{ item.name }}
-
-
-
-
-
-
-
- {{ item.location.name }}
-
-
-
-
+
+
+
+
+
+
+
+ {{ item ? item.name : "" }}
+
+
+
+ Created
+
+
+ -
+
+ Updated
+
+
+
+
-
-
+
+
+
+
+
+
+
-
+
Details
-
+
+
+ {{ detail.text }}
+
+
+
+
+
+
+
+
+
+
@@ -466,9 +554,9 @@
-
+
Attachments
-
+
+
-
+
Purchase Details
-
+
Warranty Details
-
+
Sold Details
@@ -518,8 +609,8 @@
-
-
+
diff --git a/frontend/pages/item/[id]/index/edit.vue b/frontend/pages/item/[id]/index/edit.vue
index f40ce91..93fda07 100644
--- a/frontend/pages/item/[id]/index/edit.vue
+++ b/frontend/pages/item/[id]/index/edit.vue
@@ -1,10 +1,13 @@
@@ -365,7 +418,6 @@
Attachment Edit
- {{ editState.type }}
+
+
+
+ Primary Photo
+ This options is only available for photos. Only one photo can be primary. If you select this option, the
+ current primary photo, if any will be unselected.
+
+
Update
-
-
+
+
+
+
+
+ Advanced
+
+
+
+
+
+
+ Save
+
+
+
+ Delete
+
+
+
Edit Details
-
-
-
-
- Advanced
-
-
-
-
-
-
- Save
-
-
+
@@ -514,12 +579,12 @@
diff --git a/frontend/pages/item/[id]/index/log.vue b/frontend/pages/item/[id]/index/maintenance.vue
similarity index 65%
rename from frontend/pages/item/[id]/index/log.vue
rename to frontend/pages/item/[id]/index/maintenance.vue
index 37f546e..9feb7bc 100644
--- a/frontend/pages/item/[id]/index/log.vue
+++ b/frontend/pages/item/[id]/index/maintenance.vue
@@ -1,7 +1,14 @@
@@ -142,16 +179,19 @@