@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.
Files changed (67) hide show
  1. package/.storybook/main.js +23 -0
  2. package/.storybook/preview.js +17 -0
  3. package/README.md +16 -0
  4. package/app/qInventory/App.vue +164 -0
  5. package/app/qInventory/index.html +12 -0
  6. package/app/qInventory/main.ts +4 -0
  7. package/app/qInventory/styles.scss +59 -0
  8. package/dist/components/InventoryAssetDetail.vue.d.ts +8 -0
  9. package/dist/components/InventoryAssetDetail.vue.d.ts.map +1 -0
  10. package/dist/components/InventoryLocationAdmin.vue.d.ts +10 -0
  11. package/dist/components/InventoryLocationAdmin.vue.d.ts.map +1 -0
  12. package/dist/components/InventoryLocationDetail.vue.d.ts +8 -0
  13. package/dist/components/InventoryLocationDetail.vue.d.ts.map +1 -0
  14. package/dist/components/InventoryOverview.vue.d.ts +15 -0
  15. package/dist/components/InventoryOverview.vue.d.ts.map +1 -0
  16. package/dist/components/InventoryScanner.vue.d.ts +21 -0
  17. package/dist/components/InventoryScanner.vue.d.ts.map +1 -0
  18. package/dist/components/QInventory.vue.d.ts +24 -0
  19. package/dist/components/QInventory.vue.d.ts.map +1 -0
  20. package/dist/index.d.ts +16 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/mixins/CSSMixin.d.ts +7 -0
  23. package/dist/mixins/CSSMixin.d.ts.map +1 -0
  24. package/dist/models/index.d.ts +62 -0
  25. package/dist/models/index.d.ts.map +1 -0
  26. package/dist/models/inventoryAssetRepoAxios.d.ts +18 -0
  27. package/dist/models/inventoryAssetRepoAxios.d.ts.map +1 -0
  28. package/dist/models/inventoryAuthRepoAxios.d.ts +26 -0
  29. package/dist/models/inventoryAuthRepoAxios.d.ts.map +1 -0
  30. package/dist/models/inventoryAxiosBase.d.ts +41 -0
  31. package/dist/models/inventoryAxiosBase.d.ts.map +1 -0
  32. package/dist/models/inventoryLocationRepoAxios.d.ts +13 -0
  33. package/dist/models/inventoryLocationRepoAxios.d.ts.map +1 -0
  34. package/dist/models/inventoryMovementRepoAxios.d.ts +13 -0
  35. package/dist/models/inventoryMovementRepoAxios.d.ts.map +1 -0
  36. package/dist/q-inventory.esm.js +961 -0
  37. package/dist/q-inventory.esm.js.map +1 -0
  38. package/dist/q-inventory.min.cjs +2 -0
  39. package/dist/q-inventory.min.cjs.map +1 -0
  40. package/dist/q-inventory.min.css +1 -0
  41. package/dist/q-inventory.min.js +2 -0
  42. package/dist/q-inventory.min.js.map +1 -0
  43. package/dist-app/qInventory/assets/q-inventory-app.css +1 -0
  44. package/dist-app/qInventory/assets/q-inventory-app.js +18 -0
  45. package/dist-app/qInventory/index.html +13 -0
  46. package/package.json +58 -0
  47. package/src/components/InventoryAssetDetail.vue +55 -0
  48. package/src/components/InventoryLocationAdmin.vue +56 -0
  49. package/src/components/InventoryLocationDetail.vue +51 -0
  50. package/src/components/InventoryOverview.vue +81 -0
  51. package/src/components/InventoryScanner.vue +315 -0
  52. package/src/components/QInventory.vue +117 -0
  53. package/src/index.ts +36 -0
  54. package/src/mixins/CSSMixin.ts +14 -0
  55. package/src/models/index.ts +114 -0
  56. package/src/models/inventoryAssetRepoAxios.ts +38 -0
  57. package/src/models/inventoryAuthRepoAxios.ts +48 -0
  58. package/src/models/inventoryAxiosBase.ts +149 -0
  59. package/src/models/inventoryLocationRepoAxios.ts +25 -0
  60. package/src/models/inventoryMovementRepoAxios.ts +24 -0
  61. package/src/styles/index.scss +148 -0
  62. package/stories/stories.scss +9 -0
  63. package/stories/stories.ts +362 -0
  64. package/tsconfig.json +29 -0
  65. package/vite.app.config.js +37 -0
  66. package/vite.config.js +68 -0
  67. 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>