@questwork/q-inventory 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.storybook/main.js +23 -0
- package/.storybook/preview.js +17 -0
- package/README.md +16 -0
- package/app/qInventory/App.vue +164 -0
- package/app/qInventory/index.html +12 -0
- package/app/qInventory/main.ts +4 -0
- package/app/qInventory/styles.scss +59 -0
- package/dist/components/InventoryAssetDetail.vue.d.ts +8 -0
- package/dist/components/InventoryAssetDetail.vue.d.ts.map +1 -0
- package/dist/components/InventoryLocationAdmin.vue.d.ts +10 -0
- package/dist/components/InventoryLocationAdmin.vue.d.ts.map +1 -0
- package/dist/components/InventoryLocationDetail.vue.d.ts +8 -0
- package/dist/components/InventoryLocationDetail.vue.d.ts.map +1 -0
- package/dist/components/InventoryOverview.vue.d.ts +15 -0
- package/dist/components/InventoryOverview.vue.d.ts.map +1 -0
- package/dist/components/InventoryScanner.vue.d.ts +21 -0
- package/dist/components/InventoryScanner.vue.d.ts.map +1 -0
- package/dist/components/QInventory.vue.d.ts +24 -0
- package/dist/components/QInventory.vue.d.ts.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/mixins/CSSMixin.d.ts +7 -0
- package/dist/mixins/CSSMixin.d.ts.map +1 -0
- package/dist/models/index.d.ts +62 -0
- package/dist/models/index.d.ts.map +1 -0
- package/dist/models/inventoryAssetRepoAxios.d.ts +18 -0
- package/dist/models/inventoryAssetRepoAxios.d.ts.map +1 -0
- package/dist/models/inventoryAuthRepoAxios.d.ts +26 -0
- package/dist/models/inventoryAuthRepoAxios.d.ts.map +1 -0
- package/dist/models/inventoryAxiosBase.d.ts +41 -0
- package/dist/models/inventoryAxiosBase.d.ts.map +1 -0
- package/dist/models/inventoryLocationRepoAxios.d.ts +13 -0
- package/dist/models/inventoryLocationRepoAxios.d.ts.map +1 -0
- package/dist/models/inventoryMovementRepoAxios.d.ts +13 -0
- package/dist/models/inventoryMovementRepoAxios.d.ts.map +1 -0
- package/dist/q-inventory.esm.js +961 -0
- package/dist/q-inventory.esm.js.map +1 -0
- package/dist/q-inventory.min.cjs +2 -0
- package/dist/q-inventory.min.cjs.map +1 -0
- package/dist/q-inventory.min.css +1 -0
- package/dist/q-inventory.min.js +2 -0
- package/dist/q-inventory.min.js.map +1 -0
- package/dist-app/qInventory/assets/q-inventory-app.css +1 -0
- package/dist-app/qInventory/assets/q-inventory-app.js +18 -0
- package/dist-app/qInventory/index.html +13 -0
- package/package.json +58 -0
- package/src/components/InventoryAssetDetail.vue +55 -0
- package/src/components/InventoryLocationAdmin.vue +56 -0
- package/src/components/InventoryLocationDetail.vue +51 -0
- package/src/components/InventoryOverview.vue +81 -0
- package/src/components/InventoryScanner.vue +315 -0
- package/src/components/QInventory.vue +117 -0
- package/src/index.ts +36 -0
- package/src/mixins/CSSMixin.ts +14 -0
- package/src/models/index.ts +114 -0
- package/src/models/inventoryAssetRepoAxios.ts +38 -0
- package/src/models/inventoryAuthRepoAxios.ts +48 -0
- package/src/models/inventoryAxiosBase.ts +149 -0
- package/src/models/inventoryLocationRepoAxios.ts +25 -0
- package/src/models/inventoryMovementRepoAxios.ts +24 -0
- package/src/styles/index.scss +148 -0
- package/stories/stories.scss +9 -0
- package/stories/stories.ts +362 -0
- package/tsconfig.json +29 -0
- package/vite.app.config.js +37 -0
- package/vite.config.js +68 -0
- package/vitest.config.js +11 -0
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@questwork/q-inventory",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Questwork QInventory",
|
|
5
|
+
"main": "./dist/q-inventory.min.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"require": "./dist/q-inventory.min.cjs",
|
|
10
|
+
"import": "./dist/q-inventory.esm.js",
|
|
11
|
+
"default": "./dist/q-inventory.min.js",
|
|
12
|
+
"types": "./dist/q-inventory.d.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"types": "./dist/q-inventory.d.ts",
|
|
16
|
+
"author": {
|
|
17
|
+
"name": "Questwork Consulting Limited",
|
|
18
|
+
"email": "info@questwork.com",
|
|
19
|
+
"url": "https://questwork.com/"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@questwork/q-utilities": "^0.1.31",
|
|
24
|
+
"vue": "^3.5.13"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@questwork/q-utilities": "^0.1.36",
|
|
28
|
+
"@storybook/addon-links": "^8.4.7",
|
|
29
|
+
"@storybook/addon-viewport": "^8.4.7",
|
|
30
|
+
"@storybook/vue3": "^8.4.7",
|
|
31
|
+
"@storybook/vue3-vite": "^8.4.7",
|
|
32
|
+
"@vitejs/plugin-vue": "^5.2.1",
|
|
33
|
+
"eslint": "^9.39.4",
|
|
34
|
+
"happy-dom": "^15.11.7",
|
|
35
|
+
"sass": "^1.83.1",
|
|
36
|
+
"storybook": "^8.4.7",
|
|
37
|
+
"typescript": "^5.9.3",
|
|
38
|
+
"vite": "^5.4.10",
|
|
39
|
+
"vite-plugin-commonjs": "^0.10.3",
|
|
40
|
+
"vite-plugin-dts": "^4.5.4",
|
|
41
|
+
"vitest": "^2.1.4",
|
|
42
|
+
"vue": "^3.5.13"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=20.0.0"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@questwork/q-inventory-model": "^0.1.0"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "vite build",
|
|
52
|
+
"build:app": "vite build -c vite.app.config.js",
|
|
53
|
+
"lint": "eslint .",
|
|
54
|
+
"storybook": "storybook dev -p 6051",
|
|
55
|
+
"test": "vitest run",
|
|
56
|
+
"test:watch": "vitest"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="inventory-asset-detail">
|
|
3
|
+
<div v-if="asset" class="inventory-asset-detail__body">
|
|
4
|
+
<h3>{{ getValue('assetTag') }}</h3>
|
|
5
|
+
<div>{{ getValue('name') }}</div>
|
|
6
|
+
<div>{{ getValue('currentLocationCode') }}</div>
|
|
7
|
+
<button type="button" @click="loadHistory">Load History</button>
|
|
8
|
+
<div
|
|
9
|
+
v-for="item in history"
|
|
10
|
+
:key="getHistoryKey(item)"
|
|
11
|
+
class="inventory-asset-detail__history"
|
|
12
|
+
>
|
|
13
|
+
{{ getHistoryText(item) }}
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
<div v-else class="inventory-asset-detail__empty">No asset selected</div>
|
|
17
|
+
</div>
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<script setup lang="ts">
|
|
21
|
+
import { ref } from 'vue'
|
|
22
|
+
import type { InventoryMovementApiClient } from '../models/index.js'
|
|
23
|
+
|
|
24
|
+
defineOptions({
|
|
25
|
+
name: 'inventory-asset-detail',
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const props = defineProps<{
|
|
29
|
+
movementApiClient?: InventoryMovementApiClient
|
|
30
|
+
asset?: unknown
|
|
31
|
+
}>()
|
|
32
|
+
|
|
33
|
+
const history = ref<unknown[]>([])
|
|
34
|
+
|
|
35
|
+
async function loadHistory() {
|
|
36
|
+
const assetTag = getValue('assetTag')
|
|
37
|
+
if (assetTag && props.movementApiClient?.findAll) {
|
|
38
|
+
history.value = await props.movementApiClient.findAll({ assetTag })
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getValue(key: string) {
|
|
43
|
+
return String((props.asset as Record<string, unknown>)?.[key] || '')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getHistoryKey(item: unknown) {
|
|
47
|
+
const data = item as Record<string, unknown>
|
|
48
|
+
return String(data.id || data.inventoryMovementCode || data.movedAt || JSON.stringify(item))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getHistoryText(item: unknown) {
|
|
52
|
+
const data = item as Record<string, unknown>
|
|
53
|
+
return `${data.fromLocationCode || '-'} -> ${data.toLocationCode || '-'}`
|
|
54
|
+
}
|
|
55
|
+
</script>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="inventory-location-admin">
|
|
3
|
+
<form class="inventory-location-admin__form" @submit.prevent="submit">
|
|
4
|
+
<input v-model="form.locationCode" placeholder="Location code" />
|
|
5
|
+
<input v-model="form.name" placeholder="Name" />
|
|
6
|
+
<input v-model="form.locationType" placeholder="Type" />
|
|
7
|
+
<button type="submit">Save</button>
|
|
8
|
+
</form>
|
|
9
|
+
<div
|
|
10
|
+
v-for="location in locations"
|
|
11
|
+
:key="getLocationKey(location)"
|
|
12
|
+
class="inventory-location-admin__row"
|
|
13
|
+
>
|
|
14
|
+
{{ getLocationText(location) }}
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
</template>
|
|
18
|
+
|
|
19
|
+
<script setup lang="ts">
|
|
20
|
+
import { reactive } from 'vue'
|
|
21
|
+
|
|
22
|
+
defineOptions({
|
|
23
|
+
name: 'inventory-location-admin',
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
defineProps<{
|
|
27
|
+
locations?: unknown[]
|
|
28
|
+
}>()
|
|
29
|
+
|
|
30
|
+
const emit = defineEmits<{
|
|
31
|
+
saveLocation: [payload: Record<string, string>]
|
|
32
|
+
}>()
|
|
33
|
+
|
|
34
|
+
const form = reactive({
|
|
35
|
+
locationCode: '',
|
|
36
|
+
locationType: '',
|
|
37
|
+
name: '',
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
function submit() {
|
|
41
|
+
emit('saveLocation', { ...form })
|
|
42
|
+
form.locationCode = ''
|
|
43
|
+
form.locationType = ''
|
|
44
|
+
form.name = ''
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getLocationKey(location: unknown) {
|
|
48
|
+
const data = location as Record<string, unknown>
|
|
49
|
+
return String(data.id || data.locationCode || JSON.stringify(location))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getLocationText(location: unknown) {
|
|
53
|
+
const data = location as Record<string, unknown>
|
|
54
|
+
return `${data.locationCode || ''} ${data.name || ''}`.trim()
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="inventory-location-detail">
|
|
3
|
+
<div class="inventory-location-detail__header">
|
|
4
|
+
<div>{{ locationCode || 'No location selected' }}</div>
|
|
5
|
+
<button type="button" :disabled="!locationCode" @click="loadAssets">Load Assets</button>
|
|
6
|
+
</div>
|
|
7
|
+
<div
|
|
8
|
+
v-for="asset in assets"
|
|
9
|
+
:key="getAssetKey(asset)"
|
|
10
|
+
class="inventory-location-detail__asset"
|
|
11
|
+
>
|
|
12
|
+
{{ getAssetText(asset) }}
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
</template>
|
|
16
|
+
|
|
17
|
+
<script setup lang="ts">
|
|
18
|
+
import { computed, ref } from 'vue'
|
|
19
|
+
import type { InventoryAssetApiClient } from '../models/index.js'
|
|
20
|
+
|
|
21
|
+
defineOptions({
|
|
22
|
+
name: 'inventory-location-detail',
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const props = defineProps<{
|
|
26
|
+
assetApiClient?: InventoryAssetApiClient
|
|
27
|
+
location?: unknown
|
|
28
|
+
}>()
|
|
29
|
+
|
|
30
|
+
const assets = ref<unknown[]>([])
|
|
31
|
+
const locationCode = computed(() => {
|
|
32
|
+
if (typeof props.location === 'string') return props.location
|
|
33
|
+
return String((props.location as Record<string, unknown>)?.locationCode || '')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
async function loadAssets() {
|
|
37
|
+
if (locationCode.value && props.assetApiClient?.findAll) {
|
|
38
|
+
assets.value = await props.assetApiClient.findAll({ currentLocationCode: locationCode.value })
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getAssetKey(asset: unknown) {
|
|
43
|
+
const data = asset as Record<string, unknown>
|
|
44
|
+
return String(data.id || data.assetTag || JSON.stringify(asset))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getAssetText(asset: unknown) {
|
|
48
|
+
const data = asset as Record<string, unknown>
|
|
49
|
+
return `${data.assetTag || ''} ${data.name || ''}`.trim()
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="inventory-overview">
|
|
3
|
+
<div class="inventory-overview__header">
|
|
4
|
+
<div>Assets</div>
|
|
5
|
+
<button type="button" @click="reload">Reload</button>
|
|
6
|
+
</div>
|
|
7
|
+
<div class="inventory-overview__rows">
|
|
8
|
+
<button
|
|
9
|
+
v-for="asset in displayAssets"
|
|
10
|
+
:key="getAssetKey(asset)"
|
|
11
|
+
class="inventory-overview__row"
|
|
12
|
+
type="button"
|
|
13
|
+
@click="$emit('selectAsset', asset)"
|
|
14
|
+
>
|
|
15
|
+
<span>{{ getAssetText(asset, 'assetTag') }}</span>
|
|
16
|
+
<span>{{ getAssetText(asset, 'name') }}</span>
|
|
17
|
+
<span
|
|
18
|
+
class="inventory-overview__location"
|
|
19
|
+
@click.stop="$emit('selectLocation', getAssetText(asset, 'currentLocationCode'))"
|
|
20
|
+
>
|
|
21
|
+
{{ getDisplayLocation(asset) }}
|
|
22
|
+
</span>
|
|
23
|
+
</button>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</template>
|
|
27
|
+
|
|
28
|
+
<script setup lang="ts">
|
|
29
|
+
import { computed, ref } from 'vue'
|
|
30
|
+
import {
|
|
31
|
+
getVirtualUserCode,
|
|
32
|
+
isVirtualInventoryLocation,
|
|
33
|
+
isVirtualUserLocationCode,
|
|
34
|
+
} from '@questwork/q-inventory-model'
|
|
35
|
+
import type { InventoryAssetApiClient } from '../models/index.js'
|
|
36
|
+
|
|
37
|
+
defineOptions({
|
|
38
|
+
name: 'inventory-overview',
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const props = defineProps<{
|
|
42
|
+
assetApiClient?: InventoryAssetApiClient
|
|
43
|
+
assets?: unknown[]
|
|
44
|
+
locations?: unknown[]
|
|
45
|
+
}>()
|
|
46
|
+
|
|
47
|
+
defineEmits<{
|
|
48
|
+
selectAsset: [payload: unknown]
|
|
49
|
+
selectLocation: [payload: unknown]
|
|
50
|
+
}>()
|
|
51
|
+
|
|
52
|
+
const loadedAssets = ref<unknown[]>([])
|
|
53
|
+
const displayAssets = computed(() => loadedAssets.value.length > 0 ? loadedAssets.value : (props.assets || []))
|
|
54
|
+
|
|
55
|
+
async function reload() {
|
|
56
|
+
if (props.assetApiClient?.findAll) {
|
|
57
|
+
loadedAssets.value = await props.assetApiClient.findAll()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getAssetKey(asset: unknown) {
|
|
62
|
+
return getAssetText(asset, 'id') || getAssetText(asset, 'assetTag') || JSON.stringify(asset)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getAssetText(asset: unknown, key: string) {
|
|
66
|
+
return String((asset as Record<string, unknown>)?.[key] || '')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getDisplayLocation(asset: unknown) {
|
|
70
|
+
const locationCode = getAssetText(asset, 'currentLocationCode')
|
|
71
|
+
const locationKind = getAssetText(asset, 'currentLocationKind')
|
|
72
|
+
if (!locationCode) return 'Never scanned'
|
|
73
|
+
if (isVirtualInventoryLocation({ locationCode, locationKind }) || isVirtualUserLocationCode(locationCode)) {
|
|
74
|
+
return `In transit - ${getVirtualUserCode(locationCode)}`
|
|
75
|
+
}
|
|
76
|
+
const location = (props.locations || []).find((item) => {
|
|
77
|
+
return (item as Record<string, unknown>).locationCode === locationCode
|
|
78
|
+
}) as Record<string, unknown> | undefined
|
|
79
|
+
return String(location?.name || locationCode)
|
|
80
|
+
}
|
|
81
|
+
</script>
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="inventory-scanner">
|
|
3
|
+
<div class="inventory-scanner__mode">
|
|
4
|
+
<button
|
|
5
|
+
class="inventory-scanner__mode-button"
|
|
6
|
+
:class="{ 'inventory-scanner__mode-button--active': mode === INVENTORY_MOVEMENT_TYPE.PICKUP }"
|
|
7
|
+
type="button"
|
|
8
|
+
@click="setMode(INVENTORY_MOVEMENT_TYPE.PICKUP)"
|
|
9
|
+
>
|
|
10
|
+
Pickup
|
|
11
|
+
</button>
|
|
12
|
+
<button
|
|
13
|
+
class="inventory-scanner__mode-button"
|
|
14
|
+
:class="{ 'inventory-scanner__mode-button--active': mode === INVENTORY_MOVEMENT_TYPE.DROPOFF }"
|
|
15
|
+
type="button"
|
|
16
|
+
@click="setMode(INVENTORY_MOVEMENT_TYPE.DROPOFF)"
|
|
17
|
+
>
|
|
18
|
+
Drop-off
|
|
19
|
+
</button>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="inventory-scanner__state">
|
|
22
|
+
<div>Mode: {{ mode }}</div>
|
|
23
|
+
<div>{{ locationLabel }}: {{ activeLocationCode || '-' }}</div>
|
|
24
|
+
<div>Holder: {{ holderLocationCode || '-' }}</div>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="inventory-scanner__manual">
|
|
27
|
+
<input
|
|
28
|
+
v-model="manualQr"
|
|
29
|
+
class="inventory-scanner__input"
|
|
30
|
+
placeholder="Scan or enter QR text"
|
|
31
|
+
@keyup.enter="submitManualQr"
|
|
32
|
+
/>
|
|
33
|
+
<button type="button" @click="submitManualQr">Submit</button>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="inventory-scanner__basket-header">
|
|
36
|
+
<div>
|
|
37
|
+
<strong>My Basket</strong>
|
|
38
|
+
<span>{{ basketItems.length }} on hand</span>
|
|
39
|
+
</div>
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
:disabled="scanLog.length === 0"
|
|
43
|
+
@click="clearScanLog"
|
|
44
|
+
>
|
|
45
|
+
Clear log
|
|
46
|
+
</button>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="inventory-scanner__basket">
|
|
49
|
+
<div
|
|
50
|
+
v-if="basketItems.length === 0"
|
|
51
|
+
class="inventory-scanner__empty"
|
|
52
|
+
>
|
|
53
|
+
No items on hand
|
|
54
|
+
</div>
|
|
55
|
+
<div
|
|
56
|
+
v-for="item in basketItems"
|
|
57
|
+
:key="item.assetTag"
|
|
58
|
+
class="inventory-scanner__basket-item"
|
|
59
|
+
>
|
|
60
|
+
<strong>{{ item.assetTag }}</strong>
|
|
61
|
+
<small>{{ item.name || item.category || item.currentLocationCode }}</small>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="inventory-scanner__log">
|
|
65
|
+
<div
|
|
66
|
+
v-for="item in scanLog"
|
|
67
|
+
:key="item.key"
|
|
68
|
+
class="inventory-scanner__log-item"
|
|
69
|
+
:class="{
|
|
70
|
+
'inventory-scanner__log-item--failed': item.status === 'FAILED',
|
|
71
|
+
'inventory-scanner__log-item--warning': item.status === 'WARNING',
|
|
72
|
+
}"
|
|
73
|
+
>
|
|
74
|
+
<div>
|
|
75
|
+
<strong>{{ item.title }}</strong>
|
|
76
|
+
<span>{{ item.status }}</span>
|
|
77
|
+
</div>
|
|
78
|
+
<small>{{ item.message }}</small>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
<div
|
|
82
|
+
v-if="scanLog.length === 0"
|
|
83
|
+
class="inventory-scanner__log-empty"
|
|
84
|
+
>
|
|
85
|
+
Scan log will appear here
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</template>
|
|
89
|
+
|
|
90
|
+
<script setup lang="ts">
|
|
91
|
+
import { computed, ref, watch } from 'vue'
|
|
92
|
+
import {
|
|
93
|
+
INVENTORY_LOCATION_KIND,
|
|
94
|
+
INVENTORY_MOVEMENT_TYPE,
|
|
95
|
+
getVirtualUserLocationCode,
|
|
96
|
+
isLocationQr,
|
|
97
|
+
parseInventoryQr,
|
|
98
|
+
} from '@questwork/q-inventory-model'
|
|
99
|
+
import type { InventoryAssetApiClient, InventoryMovePayload, InventoryMoveResult, InventoryMoveWarning } from '../models/index.js'
|
|
100
|
+
|
|
101
|
+
defineOptions({
|
|
102
|
+
name: 'inventory-scanner',
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
type ScannerMode = typeof INVENTORY_MOVEMENT_TYPE.PICKUP | typeof INVENTORY_MOVEMENT_TYPE.DROPOFF
|
|
106
|
+
type ScanLogStatus = 'OK' | 'WARNING' | 'FAILED' | 'INFO'
|
|
107
|
+
|
|
108
|
+
interface BasketItem {
|
|
109
|
+
assetTag: string
|
|
110
|
+
category?: string
|
|
111
|
+
currentLocationCode?: string
|
|
112
|
+
name?: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface ScanLogItem {
|
|
116
|
+
key: string
|
|
117
|
+
message: string
|
|
118
|
+
status: ScanLogStatus
|
|
119
|
+
title: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const props = defineProps<{
|
|
123
|
+
assetApiClient?: InventoryAssetApiClient
|
|
124
|
+
assets?: unknown[]
|
|
125
|
+
currentUserCode?: string
|
|
126
|
+
}>()
|
|
127
|
+
|
|
128
|
+
const emit = defineEmits<{
|
|
129
|
+
moved: [payload: InventoryMovePayload]
|
|
130
|
+
scan: [payload: { raw: string, parsed: unknown }]
|
|
131
|
+
}>()
|
|
132
|
+
|
|
133
|
+
const activeLocationCode = ref('')
|
|
134
|
+
const basketItems = ref<BasketItem[]>([])
|
|
135
|
+
const manualQr = ref('')
|
|
136
|
+
const mode = ref<ScannerMode>(INVENTORY_MOVEMENT_TYPE.PICKUP)
|
|
137
|
+
const scanLog = ref<ScanLogItem[]>([])
|
|
138
|
+
|
|
139
|
+
const holderLocationCode = computed(() => getVirtualUserLocationCode(props.currentUserCode || 'currentUser'))
|
|
140
|
+
const locationLabel = computed(() => mode.value === INVENTORY_MOVEMENT_TYPE.PICKUP ? 'Source' : 'Destination')
|
|
141
|
+
|
|
142
|
+
watch(
|
|
143
|
+
() => [props.assets, holderLocationCode.value],
|
|
144
|
+
syncBasketFromAssets,
|
|
145
|
+
{ deep: true, immediate: true },
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
function setMode(nextMode: ScannerMode) {
|
|
149
|
+
mode.value = nextMode
|
|
150
|
+
activeLocationCode.value = ''
|
|
151
|
+
addLog({
|
|
152
|
+
message: `Mode changed to ${nextMode}`,
|
|
153
|
+
status: 'INFO',
|
|
154
|
+
title: 'Mode',
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function submitManualQr() {
|
|
159
|
+
const value = manualQr.value.trim()
|
|
160
|
+
if (!value) return
|
|
161
|
+
manualQr.value = ''
|
|
162
|
+
await handleQr(value)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function handleQr(value: string) {
|
|
166
|
+
const parsed = parseInventoryQr(value)
|
|
167
|
+
emit('scan', { raw: value, parsed })
|
|
168
|
+
|
|
169
|
+
if (isLocationQr(value)) {
|
|
170
|
+
activeLocationCode.value = parsed.code
|
|
171
|
+
addLog({
|
|
172
|
+
message: `${locationLabel.value} set to ${parsed.code}`,
|
|
173
|
+
status: 'INFO',
|
|
174
|
+
title: 'Location',
|
|
175
|
+
})
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!activeLocationCode.value) {
|
|
180
|
+
addLog({
|
|
181
|
+
message: `Scan ${locationLabel.value.toLowerCase()} location first`,
|
|
182
|
+
status: 'FAILED',
|
|
183
|
+
title: parsed.code,
|
|
184
|
+
})
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const payload = getMovePayload(parsed.code)
|
|
189
|
+
try {
|
|
190
|
+
if (props.assetApiClient?.move) {
|
|
191
|
+
const result = await props.assetApiClient.move(payload)
|
|
192
|
+
const warnings = getMoveWarnings(result)
|
|
193
|
+
emit('moved', payload)
|
|
194
|
+
applyBasketMove(payload)
|
|
195
|
+
if (warnings.length) {
|
|
196
|
+
addLog({
|
|
197
|
+
message: formatWarnings(warnings),
|
|
198
|
+
status: 'WARNING',
|
|
199
|
+
title: parsed.code,
|
|
200
|
+
})
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
emit('moved', payload)
|
|
205
|
+
applyBasketMove(payload)
|
|
206
|
+
}
|
|
207
|
+
addLog({
|
|
208
|
+
message: `${payload.fromLocationCode || '-'} -> ${payload.toLocationCode}`,
|
|
209
|
+
status: 'OK',
|
|
210
|
+
title: parsed.code,
|
|
211
|
+
})
|
|
212
|
+
} catch (err) {
|
|
213
|
+
addLog({
|
|
214
|
+
message: getErrorMessage(err),
|
|
215
|
+
status: 'FAILED',
|
|
216
|
+
title: parsed.code,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function getMovePayload(assetTag: string): InventoryMovePayload {
|
|
222
|
+
if (mode.value === INVENTORY_MOVEMENT_TYPE.PICKUP) {
|
|
223
|
+
return {
|
|
224
|
+
assetTag,
|
|
225
|
+
fromLocationCode: activeLocationCode.value,
|
|
226
|
+
fromLocationKind: INVENTORY_LOCATION_KIND.REGISTERED,
|
|
227
|
+
movementType: INVENTORY_MOVEMENT_TYPE.PICKUP,
|
|
228
|
+
toLocationCode: holderLocationCode.value,
|
|
229
|
+
toLocationKind: INVENTORY_LOCATION_KIND.VIRTUAL,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
assetTag,
|
|
234
|
+
fromLocationCode: holderLocationCode.value,
|
|
235
|
+
fromLocationKind: INVENTORY_LOCATION_KIND.VIRTUAL,
|
|
236
|
+
movementType: INVENTORY_MOVEMENT_TYPE.DROPOFF,
|
|
237
|
+
toLocationCode: activeLocationCode.value,
|
|
238
|
+
toLocationKind: INVENTORY_LOCATION_KIND.REGISTERED,
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function applyBasketMove(payload: InventoryMovePayload) {
|
|
243
|
+
if (payload.toLocationCode === holderLocationCode.value) {
|
|
244
|
+
upsertBasketItem({
|
|
245
|
+
assetTag: payload.assetTag,
|
|
246
|
+
currentLocationCode: holderLocationCode.value,
|
|
247
|
+
})
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
basketItems.value = basketItems.value.filter((item) => item.assetTag !== payload.assetTag)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function upsertBasketItem(item: BasketItem) {
|
|
254
|
+
const index = basketItems.value.findIndex((basketItem) => basketItem.assetTag === item.assetTag)
|
|
255
|
+
if (index >= 0) {
|
|
256
|
+
basketItems.value[index] = {
|
|
257
|
+
...basketItems.value[index],
|
|
258
|
+
...item,
|
|
259
|
+
}
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
basketItems.value.unshift(item)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function syncBasketFromAssets() {
|
|
266
|
+
basketItems.value = (props.assets || [])
|
|
267
|
+
.map(toBasketItem)
|
|
268
|
+
.filter((item): item is BasketItem => !!item && item.currentLocationCode === holderLocationCode.value)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function toBasketItem(asset: unknown): BasketItem | null {
|
|
272
|
+
const item = asset as Record<string, unknown>
|
|
273
|
+
const assetTag = getString(item.assetTag)
|
|
274
|
+
if (!assetTag) {
|
|
275
|
+
return null
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
assetTag,
|
|
279
|
+
category: getString(item.category),
|
|
280
|
+
currentLocationCode: getString(item.currentLocationCode),
|
|
281
|
+
name: getString(item.name),
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function addLog({ message, status, title }: Omit<ScanLogItem, 'key'>) {
|
|
286
|
+
scanLog.value.unshift({
|
|
287
|
+
key: `${Date.now()}-${Math.random()}`,
|
|
288
|
+
message,
|
|
289
|
+
status,
|
|
290
|
+
title,
|
|
291
|
+
})
|
|
292
|
+
scanLog.value = scanLog.value.slice(0, 30)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function getString(value: unknown) {
|
|
296
|
+
return typeof value === 'string' ? value : ''
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function getErrorMessage(err: unknown) {
|
|
300
|
+
return err instanceof Error ? err.message : String(err)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function getMoveWarnings(result: unknown) {
|
|
304
|
+
const response = result as { data?: InventoryMoveResult[] }
|
|
305
|
+
return response?.data?.[0]?.warnings || []
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function formatWarnings(warnings: InventoryMoveWarning[]) {
|
|
309
|
+
return warnings.map((warning) => `${warning.code}: ${warning.message}`).join('; ')
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function clearScanLog() {
|
|
313
|
+
scanLog.value = []
|
|
314
|
+
}
|
|
315
|
+
</script>
|