diff --git a/backend/internal/data/repo/repo_items.go b/backend/internal/data/repo/repo_items.go index e5dd607..91dd2b3 100644 --- a/backend/internal/data/repo/repo_items.go +++ b/backend/internal/data/repo/repo_items.go @@ -308,22 +308,6 @@ func (e *ItemsRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q Ite qb = qb.Where(item.Archived(false)) } - if len(q.LabelIDs) > 0 { - labels := make([]predicate.Item, 0, len(q.LabelIDs)) - for _, l := range q.LabelIDs { - labels = append(labels, item.HasLabelWith(label.ID(l))) - } - qb = qb.Where(item.Or(labels...)) - } - - if len(q.LocationIDs) > 0 { - locations := make([]predicate.Item, 0, len(q.LocationIDs)) - for _, l := range q.LocationIDs { - locations = append(locations, item.HasLocationWith(location.ID(l))) - } - qb = qb.Where(item.Or(locations...)) - } - if q.Search != "" { qb.Where( item.Or( @@ -338,18 +322,50 @@ func (e *ItemsRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q Ite qb = qb.Where(item.AssetID(q.AssetID.Int())) } - if len(q.Fields) > 0 { - predicates := make([]predicate.Item, 0, len(q.Fields)) - for _, f := range q.Fields { - predicates = append(predicates, item.HasFieldsWith( - itemfield.And( - itemfield.Name(f.Name), - itemfield.TextValue(f.Value), - ), - )) + // Filters within this block define a AND relationship where each subset + // of filters is OR'd together. + // + // The goal is to allow matches like where the item has + // - one of the selected labels AND + // - one of the selected locations AND + // - one of the selected fields key/value matches + var andPredicates []predicate.Item + { + if len(q.LabelIDs) > 0 { + labelPredicates := make([]predicate.Item, 0, len(q.LabelIDs)) + for _, l := range q.LabelIDs { + labelPredicates = append(labelPredicates, item.HasLabelWith(label.ID(l))) + } + + andPredicates = append(andPredicates, item.Or(labelPredicates...)) } - qb = qb.Where(item.Or(predicates...)) + if len(q.LocationIDs) > 0 { + locationPredicates := make([]predicate.Item, 0, len(q.LocationIDs)) + for _, l := range q.LocationIDs { + locationPredicates = append(locationPredicates, item.HasLocationWith(location.ID(l))) + } + + andPredicates = append(andPredicates, item.Or(locationPredicates...)) + } + + if len(q.Fields) > 0 { + fieldPredicates := make([]predicate.Item, 0, len(q.Fields)) + for _, f := range q.Fields { + fieldPredicates = append(fieldPredicates, item.HasFieldsWith( + itemfield.And( + itemfield.Name(f.Name), + itemfield.TextValue(f.Value), + ), + )) + } + + andPredicates = append(andPredicates, item.Or(fieldPredicates...)) + } + } + + if len(andPredicates) > 0 { + qb = qb.Where(item.And(andPredicates...)) } count, err := qb.Count(ctx) diff --git a/frontend/assets/css/main.css b/frontend/assets/css/main.css index f76ff5f..89b2e1c 100644 --- a/frontend/assets/css/main.css +++ b/frontend/assets/css/main.css @@ -6,3 +6,21 @@ text-transform: none !important; } +/* transparent subtle scrollbar */ +::-webkit-scrollbar { + width: 0.2em; + background-color: #F5F5F5; +} + +::-webkit-scrollbar-thumb { + background-color: rgba(0,0,0,.2); +} + +::-webkit-scrollbar-track { + -webkit-box-shadow: inset 0 0 6px rgba(0,0,0,0.3); + background-color: #F5F5F5; +} + +::-webkit-scrollbar-thumb:hover { + background-color: #9B9B9B; +} \ No newline at end of file diff --git a/frontend/components/Form/Autocomplete2.vue b/frontend/components/Form/Autocomplete2.vue new file mode 100644 index 0000000..164386e --- /dev/null +++ b/frontend/components/Form/Autocomplete2.vue @@ -0,0 +1,157 @@ + + + + + {{ label }} + + + + + + + + + + + + {{ item.display }} + + + + + + + + + + + + + + diff --git a/frontend/components/Location/Selector.vue b/frontend/components/Location/Selector.vue index e519af3..82b7954 100644 --- a/frontend/components/Location/Selector.vue +++ b/frontend/components/Location/Selector.vue @@ -1,37 +1,43 @@ - - + + - - {{ item.name }} + + {{ cast(item.value).name }} + + + + + + {{ cast(item.value).treeString }} - {{ item.display }} - + + + diff --git a/frontend/composables/use-location-helpers.ts b/frontend/composables/use-location-helpers.ts index a540301..f9701de 100644 --- a/frontend/composables/use-location-helpers.ts +++ b/frontend/composables/use-location-helpers.ts @@ -4,7 +4,7 @@ import { TreeItem } from "~~/lib/api/types/data-contracts"; export interface FlatTreeItem { id: string; name: string; - display: string; + treeString: string; } export function flatTree(tree: TreeItem[]): Ref { @@ -18,7 +18,7 @@ export function flatTree(tree: TreeItem[]): Ref { v.value.push({ id: item.id, name: item.name, - display: display + item.name, + treeString: display + item.name, }); if (item.children) { flatten(item.children, display + item.name + " > "); diff --git a/frontend/lib/api/types/data-contracts.ts b/frontend/lib/api/types/data-contracts.ts index 0a8538a..ff2517a 100644 --- a/frontend/lib/api/types/data-contracts.ts +++ b/frontend/lib/api/types/data-contracts.ts @@ -17,7 +17,7 @@ export interface DocumentOut { } export interface Group { - createdAt: Date; + createdAt: string; currency: string; id: string; name: string; @@ -39,7 +39,7 @@ export interface GroupUpdate { } export interface ItemAttachment { - createdAt: Date; + createdAt: string; document: DocumentOut; id: string; type: string; @@ -76,7 +76,7 @@ export interface ItemOut { assetId: string; attachments: ItemAttachment[]; children: ItemSummary[]; - createdAt: Date; + createdAt: string; description: string; fields: ItemField[]; id: string; @@ -112,7 +112,7 @@ export interface ItemOut { export interface ItemSummary { archived: boolean; - createdAt: Date; + createdAt: string; description: string; id: string; insured: boolean; @@ -169,7 +169,7 @@ export interface LabelCreate { } export interface LabelOut { - createdAt: Date; + createdAt: string; description: string; id: string; items: ItemSummary[]; @@ -178,7 +178,7 @@ export interface LabelOut { } export interface LabelSummary { - createdAt: Date; + createdAt: string; description: string; id: string; name: string; @@ -193,7 +193,7 @@ export interface LocationCreate { export interface LocationOut { children: LocationSummary[]; - createdAt: Date; + createdAt: string; description: string; id: string; items: ItemSummary[]; @@ -203,7 +203,7 @@ export interface LocationOut { } export interface LocationOutCount { - createdAt: Date; + createdAt: string; description: string; id: string; itemCount: number; @@ -212,7 +212,7 @@ export interface LocationOutCount { } export interface LocationSummary { - createdAt: Date; + createdAt: string; description: string; id: string; name: string; @@ -229,7 +229,7 @@ export interface LocationUpdate { export interface MaintenanceEntry { /** @example "0" */ cost: string; - date: string; + date: Date; description: string; id: string; name: string; @@ -238,7 +238,7 @@ export interface MaintenanceEntry { export interface MaintenanceEntryCreate { /** @example "0" */ cost: string; - date: string; + date: Date; description: string; name: string; } @@ -246,7 +246,7 @@ export interface MaintenanceEntryCreate { export interface MaintenanceEntryUpdate { /** @example "0" */ cost: string; - date: string; + date: Date; description: string; name: string; } @@ -258,7 +258,7 @@ export interface MaintenanceLog { itemId: string; } -export interface PaginationResultItemSummary { +export interface PaginationResultRepoItemSummary { items: ItemSummary[]; page: number; pageSize: number; @@ -302,7 +302,7 @@ export interface ValueOverTime { } export interface ValueOverTimeEntry { - date: string; + date: Date; name: string; value: number; } diff --git a/frontend/package.json b/frontend/package.json index 991f3f9..ce2c1c0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ "vitest": "^0.28.0" }, "dependencies": { + "@headlessui/vue": "^1.7.9", "@iconify/vue": "^3.2.1", "@nuxtjs/tailwindcss": "^6.1.3", "@pinia/nuxt": "^0.4.1", diff --git a/frontend/pages/items.vue b/frontend/pages/items.vue index 0cad8d4..86e9918 100644 --- a/frontend/pages/items.vue +++ b/frontend/pages/items.vue @@ -16,7 +16,7 @@ const initialSearch = ref(true); const api = useUserApi(); - const loading = useMinLoader(2000); + const loading = useMinLoader(500); const items = ref([]); const total = ref(0); @@ -33,6 +33,7 @@ const query = useRouteQuery("q", ""); const advanced = useRouteQuery("advanced", false); const includeArchived = useRouteQuery("archived", false); + const fieldSelector = useRouteQuery("fieldSelector", false); const totalPages = computed(() => Math.ceil(total.value / pageSize.value)); const hasNext = computed(() => page.value * pageSize.value < total.value); @@ -98,6 +99,9 @@ }); const locationsStore = useLocationStore(); + + const locationFlatTree = await useFlatLocations(); + const locations = computed(() => locationsStore.allLocations); const labelStore = useLabelStore(); @@ -147,6 +151,12 @@ return data; }); + watch(fieldSelector, (newV, oldV) => { + if (newV === false && oldV === true) { + fieldTuples.value = []; + } + }); + async function fetchValues(field: string): Promise { if (fieldValuesCache.value[field]) { return fieldValuesCache.value[field]; @@ -198,6 +208,8 @@ } } + const toast = useNotifier(); + const { data, error } = await api.items.getAll({ q: query.value || "", locations: locIDs.value, @@ -208,15 +220,21 @@ fields, }); - if (error) { + function resetItems() { page.value = Math.max(1, page.value - 1); loading.value = false; + total.value = 0; + items.value = []; + } + + if (error) { + resetItems(); + toast.error("Failed to search items"); return; } if (!data.items || data.items.length === 0) { - page.value = Math.max(1, page.value - 1); - loading.value = false; + resetItems(); return; } @@ -227,7 +245,7 @@ initialSearch.value = false; } - watchDebounced([page, pageSize, query, advanced], search, { debounce: 250, maxWait: 1000 }); + watchDebounced([page, pageSize, query, selectedLabels, selectedLocations], search, { debounce: 250, maxWait: 1000 }); async function submit() { // Set URL Params @@ -243,7 +261,8 @@ query: { // Reactive advanced: "true", - includeArchived: includeArchived.value ? "true" : "false", + archived: includeArchived.value ? "true" : "false", + fieldSelector: fieldSelector.value ? "true" : "false", pageSize: pageSize.value, page: page.value, q: query.value, @@ -261,83 +280,141 @@ // Perform Search await search(); } + + async function reset() { + // Set URL Params + const fields = []; + for (const t of fieldTuples.value) { + if (t[0] && t[1]) { + fields.push(`${t[0]}=${t[1]}`); + } + } + + await router.push({ + query: { + archived: "false", + fieldSelector: "false", + pageSize: 10, + page: 1, + q: "", + loc: [], + lab: [], + fields, + }, + }); + + await search(); + } - - - Querying Asset ID Number: {{ parsedAssetId }} - - - - - Advanced Search - - - - Search Tips - - - - Location and label filters use the 'OR' operation. If more than one is selected only one will be required - for a match. - - Searches prefixed with '#'' will query for a asset ID (example '#000-001') - - Field filters use the 'OR' operation. If more than one is selected only one will be required for a match. - - - - - - - - Include Archived Items - - - - - - - Custom Fields - - - - Field - - - {{ fv }} - - - - - Field Value - - - {{ v }} - - - - - + + + + + + Querying Asset ID Number: {{ parsedAssetId }} - fieldTuples.push(['', ''])"> Add - - Search + + + + + + Search + + + + + + + + + {{ item.name }} + + + {{ item.treeString }} + + + + + + + Options + + + + Include Archived Items + + + + Field Selector + + + Reset Search + - - + + Tips + + Search Tips + + + Location and label filters use the 'OR' operation. If more than one is selected only one will be + required for a match. + + Searches prefixed with '#'' will query for a asset ID (example '#000-001') + + Field filters use the 'OR' operation. If more than one is selected only one will be required for a + match. + + + + + + + Custom Fields + + + + Field + + + {{ fv }} + + + + + Field Value + + + {{ v }} + + + + + + + fieldTuples.push(['', ''])"> Add + + + Items diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 2e839f2..7dc9350 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -2,6 +2,7 @@ lockfileVersion: 5.4 specifiers: '@faker-js/faker': ^7.5.0 + '@headlessui/vue': ^1.7.9 '@iconify/vue': ^3.2.1 '@nuxtjs/eslint-config-typescript': ^12.0.0 '@nuxtjs/tailwindcss': ^6.1.3 @@ -36,6 +37,7 @@ specifiers: vue-router: '4' dependencies: + '@headlessui/vue': 1.7.9_vue@3.2.45 '@iconify/vue': 3.2.1_vue@3.2.45 '@nuxtjs/tailwindcss': 6.1.3 '@pinia/nuxt': 0.4.6_prq2uz4lho2pwp6irk4cfkrxwu @@ -736,6 +738,15 @@ packages: engines: {node: '>=14.0.0', npm: '>=6.0.0'} dev: true + /@headlessui/vue/1.7.9_vue@3.2.45: + resolution: {integrity: sha512-vgLBKszj+m2ozaPOnjWMGnspoLJcU/06vygdEAyAS4nDjp72yA7AYbOIEgdaspUhaMs585ApyiSm3jPTuIxAzg==} + engines: {node: '>=10'} + peerDependencies: + vue: ^3.2.0 + dependencies: + vue: 3.2.45 + dev: false + /@humanwhocodes/config-array/0.11.7: resolution: {integrity: sha512-kBbPWzN8oVMLb0hOUYXhmxggL/1cJE6ydvjDIGi9EnAGUyA7cLVKQg+d/Dsm+KZwx2czGHrCmMVLiyg8s5JPKw==} engines: {node: '>=10.10.0'}
Querying Asset ID Number: {{ parsedAssetId }}
Custom Fields
Search Tips
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 2e839f2..7dc9350 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -2,6 +2,7 @@ lockfileVersion: 5.4 specifiers: '@faker-js/faker': ^7.5.0 + '@headlessui/vue': ^1.7.9 '@iconify/vue': ^3.2.1 '@nuxtjs/eslint-config-typescript': ^12.0.0 '@nuxtjs/tailwindcss': ^6.1.3 @@ -36,6 +37,7 @@ specifiers: vue-router: '4' dependencies: + '@headlessui/vue': 1.7.9_vue@3.2.45 '@iconify/vue': 3.2.1_vue@3.2.45 '@nuxtjs/tailwindcss': 6.1.3 '@pinia/nuxt': 0.4.6_prq2uz4lho2pwp6irk4cfkrxwu @@ -736,6 +738,15 @@ packages: engines: {node: '>=14.0.0', npm: '>=6.0.0'} dev: true + /@headlessui/vue/1.7.9_vue@3.2.45: + resolution: {integrity: sha512-vgLBKszj+m2ozaPOnjWMGnspoLJcU/06vygdEAyAS4nDjp72yA7AYbOIEgdaspUhaMs585ApyiSm3jPTuIxAzg==} + engines: {node: '>=10'} + peerDependencies: + vue: ^3.2.0 + dependencies: + vue: 3.2.45 + dev: false + /@humanwhocodes/config-array/0.11.7: resolution: {integrity: sha512-kBbPWzN8oVMLb0hOUYXhmxggL/1cJE6ydvjDIGi9EnAGUyA7cLVKQg+d/Dsm+KZwx2czGHrCmMVLiyg8s5JPKw==} engines: {node: '>=10.10.0'}