@codingfactory/inventory-locator-client 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 (53) hide show
  1. package/package.json +47 -0
  2. package/src/api/client.ts +31 -0
  3. package/src/components/counting/CountEntryForm.vue +833 -0
  4. package/src/components/counting/CountReport.vue +385 -0
  5. package/src/components/counting/CountSessionDetail.vue +650 -0
  6. package/src/components/counting/CountSessionList.vue +683 -0
  7. package/src/components/dashboard/InventoryDashboard.vue +670 -0
  8. package/src/components/dashboard/UnlocatedProducts.vue +468 -0
  9. package/src/components/labels/LabelBatchPrint.vue +528 -0
  10. package/src/components/labels/LabelPreview.vue +293 -0
  11. package/src/components/locations/InventoryLocatorShell.vue +408 -0
  12. package/src/components/locations/LocationBreadcrumb.vue +144 -0
  13. package/src/components/locations/LocationCodeBadge.vue +46 -0
  14. package/src/components/locations/LocationDetail.vue +884 -0
  15. package/src/components/locations/LocationForm.vue +360 -0
  16. package/src/components/locations/LocationSearchInput.vue +428 -0
  17. package/src/components/locations/LocationTree.vue +156 -0
  18. package/src/components/locations/LocationTreeNode.vue +280 -0
  19. package/src/components/locations/LocationTypeIcon.vue +58 -0
  20. package/src/components/products/LocationProductAdd.vue +637 -0
  21. package/src/components/products/LocationProductList.vue +547 -0
  22. package/src/components/products/ProductLocationList.vue +215 -0
  23. package/src/components/products/QuickMoveModal.vue +592 -0
  24. package/src/components/scanning/ScanHistory.vue +146 -0
  25. package/src/components/scanning/ScanResult.vue +350 -0
  26. package/src/components/scanning/ScannerOverlay.vue +696 -0
  27. package/src/components/shared/InvBadge.vue +71 -0
  28. package/src/components/shared/InvButton.vue +206 -0
  29. package/src/components/shared/InvCard.vue +254 -0
  30. package/src/components/shared/InvEmptyState.vue +132 -0
  31. package/src/components/shared/InvInput.vue +125 -0
  32. package/src/components/shared/InvModal.vue +296 -0
  33. package/src/components/shared/InvSelect.vue +155 -0
  34. package/src/components/shared/InvTable.vue +288 -0
  35. package/src/composables/useCountSessions.ts +184 -0
  36. package/src/composables/useLabelPrinting.ts +71 -0
  37. package/src/composables/useLocationBreadcrumbs.ts +19 -0
  38. package/src/composables/useLocationProducts.ts +125 -0
  39. package/src/composables/useLocationSearch.ts +46 -0
  40. package/src/composables/useLocations.ts +159 -0
  41. package/src/composables/useMovements.ts +71 -0
  42. package/src/composables/useScanner.ts +83 -0
  43. package/src/env.d.ts +7 -0
  44. package/src/index.ts +46 -0
  45. package/src/plugin.ts +14 -0
  46. package/src/stores/countStore.ts +95 -0
  47. package/src/stores/locationStore.ts +113 -0
  48. package/src/stores/scannerStore.ts +51 -0
  49. package/src/types/index.ts +216 -0
  50. package/src/utils/codeFormatter.ts +29 -0
  51. package/src/utils/locationIcons.ts +64 -0
  52. package/tsconfig.json +21 -0
  53. package/vite.config.ts +37 -0
@@ -0,0 +1,46 @@
1
+ import { ref } from 'vue'
2
+ import { getApiClient, apiUrl } from '../api/client'
3
+ import type { Location, LocationType } from '../types'
4
+
5
+ export function useLocationSearch() {
6
+ const results = ref<Location[]>([])
7
+ const query = ref('')
8
+ const loading = ref(false)
9
+ const error = ref<string | null>(null)
10
+
11
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null
12
+
13
+ function search(q: string, filters?: { type?: LocationType }) {
14
+ query.value = q
15
+
16
+ if (debounceTimer) clearTimeout(debounceTimer)
17
+
18
+ if (!q || q.length < 2) {
19
+ results.value = []
20
+ return
21
+ }
22
+
23
+ debounceTimer = setTimeout(async () => {
24
+ loading.value = true
25
+ error.value = null
26
+ try {
27
+ const params = new URLSearchParams({ q })
28
+ if (filters?.type) params.set('type', filters.type)
29
+ const response = await getApiClient().get(apiUrl(`/locations/search?${params}`))
30
+ results.value = response.data.data
31
+ } catch (err: any) {
32
+ error.value = err.response?.data?.message || 'Search failed'
33
+ } finally {
34
+ loading.value = false
35
+ }
36
+ }, 300)
37
+ }
38
+
39
+ function clear() {
40
+ query.value = ''
41
+ results.value = []
42
+ if (debounceTimer) clearTimeout(debounceTimer)
43
+ }
44
+
45
+ return { results, query, loading, error, search, clear }
46
+ }
@@ -0,0 +1,159 @@
1
+ import { ref } from 'vue'
2
+ import { getApiClient, apiUrl } from '../api/client'
3
+ import { useLocationStore } from '../stores/locationStore'
4
+ import type { Location, CreateLocationData, UpdateLocationData, LocationTypeInfo } from '../types'
5
+
6
+ export function useLocations() {
7
+ const locations = ref<Location[]>([])
8
+ const loading = ref(false)
9
+ const error = ref<string | null>(null)
10
+ const locationStore = useLocationStore()
11
+
12
+ async function fetchRoots(): Promise<Location[]> {
13
+ loading.value = true
14
+ error.value = null
15
+ try {
16
+ const response = await getApiClient().get(apiUrl('/locations'))
17
+ const data = response.data.data
18
+ locationStore.setRoots(data)
19
+ locations.value = data
20
+ return data
21
+ } catch (err: any) {
22
+ error.value = err.response?.data?.message || 'Failed to load locations'
23
+ throw err
24
+ } finally {
25
+ loading.value = false
26
+ }
27
+ }
28
+
29
+ async function fetchLocation(id: number): Promise<Location> {
30
+ loading.value = true
31
+ error.value = null
32
+ try {
33
+ const response = await getApiClient().get(apiUrl(`/locations/${id}?include_path=1`))
34
+ return response.data.data
35
+ } catch (err: any) {
36
+ error.value = err.response?.data?.message || 'Failed to load location'
37
+ throw err
38
+ } finally {
39
+ loading.value = false
40
+ }
41
+ }
42
+
43
+ async function fetchChildren(locationId: number): Promise<Location[]> {
44
+ const cached = locationStore.childrenCache.get(locationId)
45
+ if (cached) return cached
46
+
47
+ try {
48
+ const response = await getApiClient().get(apiUrl(`/locations/${locationId}`))
49
+ const children = response.data.data.children || []
50
+ locationStore.cacheChildren(locationId, children)
51
+ return children
52
+ } catch (err: any) {
53
+ error.value = err.response?.data?.message || 'Failed to load children'
54
+ throw err
55
+ }
56
+ }
57
+
58
+ async function fetchTree(locationId: number): Promise<any> {
59
+ loading.value = true
60
+ error.value = null
61
+ try {
62
+ const response = await getApiClient().get(apiUrl(`/locations/${locationId}/tree`))
63
+ return response.data
64
+ } catch (err: any) {
65
+ error.value = err.response?.data?.message || 'Failed to load tree'
66
+ throw err
67
+ } finally {
68
+ loading.value = false
69
+ }
70
+ }
71
+
72
+ async function create(data: CreateLocationData): Promise<Location> {
73
+ loading.value = true
74
+ error.value = null
75
+ try {
76
+ const response = await getApiClient().post(apiUrl('/locations'), data)
77
+ const location = response.data.data
78
+ await fetchRoots()
79
+ return location
80
+ } catch (err: any) {
81
+ error.value = err.response?.data?.message || 'Failed to create location'
82
+ throw err
83
+ } finally {
84
+ loading.value = false
85
+ }
86
+ }
87
+
88
+ async function update(id: number, data: UpdateLocationData): Promise<Location> {
89
+ loading.value = true
90
+ error.value = null
91
+ try {
92
+ const response = await getApiClient().put(apiUrl(`/locations/${id}`), data)
93
+ const location = response.data.data
94
+ locationStore.updateLocationInTree(location)
95
+ return location
96
+ } catch (err: any) {
97
+ error.value = err.response?.data?.message || 'Failed to update location'
98
+ throw err
99
+ } finally {
100
+ loading.value = false
101
+ }
102
+ }
103
+
104
+ async function remove(id: number): Promise<void> {
105
+ loading.value = true
106
+ error.value = null
107
+ try {
108
+ await getApiClient().delete(apiUrl(`/locations/${id}`))
109
+ locationStore.removeLocationFromTree(id)
110
+ } catch (err: any) {
111
+ error.value = err.response?.data?.message || 'Failed to delete location'
112
+ throw err
113
+ } finally {
114
+ loading.value = false
115
+ }
116
+ }
117
+
118
+ async function move(id: number, parentId: number | null): Promise<Location> {
119
+ loading.value = true
120
+ error.value = null
121
+ try {
122
+ const response = await getApiClient().post(apiUrl(`/locations/${id}/move`), { parent_id: parentId })
123
+ const location = response.data.data
124
+ await fetchRoots()
125
+ return location
126
+ } catch (err: any) {
127
+ error.value = err.response?.data?.message || 'Failed to move location'
128
+ throw err
129
+ } finally {
130
+ loading.value = false
131
+ }
132
+ }
133
+
134
+ async function fetchTypes(): Promise<LocationTypeInfo[]> {
135
+ try {
136
+ const response = await getApiClient().get(apiUrl('/locations/types'))
137
+ return response.data
138
+ } catch (err: any) {
139
+ error.value = err.response?.data?.message || 'Failed to load types'
140
+ throw err
141
+ }
142
+ }
143
+
144
+ async function scanLookup(code: string): Promise<Location | null> {
145
+ try {
146
+ const response = await getApiClient().get(apiUrl(`/scan/${encodeURIComponent(code)}`))
147
+ return response.data.data
148
+ } catch (err: any) {
149
+ if (err.response?.status === 404) return null
150
+ throw err
151
+ }
152
+ }
153
+
154
+ return {
155
+ locations, loading, error,
156
+ fetchRoots, fetchLocation, fetchChildren, fetchTree,
157
+ create, update, remove, move, fetchTypes, scanLookup,
158
+ }
159
+ }
@@ -0,0 +1,71 @@
1
+ import { ref } from 'vue'
2
+ import { getApiClient, apiUrl } from '../api/client'
3
+ import type { Movement } from '../types'
4
+
5
+ export function useMovements() {
6
+ const movements = ref<Movement[]>([])
7
+ const loading = ref(false)
8
+ const error = ref<string | null>(null)
9
+
10
+ async function fetchAll(filters?: { reason?: string; from_date?: string; to_date?: string }): Promise<void> {
11
+ loading.value = true
12
+ error.value = null
13
+ try {
14
+ const params = new URLSearchParams()
15
+ if (filters?.reason) params.set('reason', filters.reason)
16
+ if (filters?.from_date) params.set('from_date', filters.from_date)
17
+ if (filters?.to_date) params.set('to_date', filters.to_date)
18
+ const response = await getApiClient().get(apiUrl(`/movements?${params}`))
19
+ movements.value = response.data.data
20
+ } catch (err: any) {
21
+ error.value = err.response?.data?.message || 'Failed to load movements'
22
+ throw err
23
+ } finally {
24
+ loading.value = false
25
+ }
26
+ }
27
+
28
+ async function fetchByLocation(locationId: number): Promise<void> {
29
+ loading.value = true
30
+ error.value = null
31
+ try {
32
+ const response = await getApiClient().get(apiUrl(`/locations/${locationId}/movements`))
33
+ movements.value = response.data.data
34
+ } catch (err: any) {
35
+ error.value = err.response?.data?.message || 'Failed to load movements'
36
+ throw err
37
+ } finally {
38
+ loading.value = false
39
+ }
40
+ }
41
+
42
+ async function fetchByProduct(productId: number | string): Promise<void> {
43
+ loading.value = true
44
+ error.value = null
45
+ try {
46
+ const response = await getApiClient().get(apiUrl(`/products/${productId}/movements`))
47
+ movements.value = response.data.data
48
+ } catch (err: any) {
49
+ error.value = err.response?.data?.message || 'Failed to load movements'
50
+ throw err
51
+ } finally {
52
+ loading.value = false
53
+ }
54
+ }
55
+
56
+ async function fetchRecent(): Promise<void> {
57
+ loading.value = true
58
+ error.value = null
59
+ try {
60
+ const response = await getApiClient().get(apiUrl('/dashboard/recent-movements'))
61
+ movements.value = response.data.data
62
+ } catch (err: any) {
63
+ error.value = err.response?.data?.message || 'Failed to load movements'
64
+ throw err
65
+ } finally {
66
+ loading.value = false
67
+ }
68
+ }
69
+
70
+ return { movements, loading, error, fetchAll, fetchByLocation, fetchByProduct, fetchRecent }
71
+ }
@@ -0,0 +1,83 @@
1
+ import { ref, onUnmounted } from 'vue'
2
+
3
+ export function useScanner() {
4
+ const isScanning = ref(false)
5
+ const lastResult = ref<string | null>(null)
6
+ const error = ref<string | null>(null)
7
+ const isSupported = ref(false)
8
+ const hasFlash = ref(false)
9
+ const flashOn = ref(false)
10
+
11
+ let scanner: any = null
12
+
13
+ isSupported.value = typeof navigator !== 'undefined'
14
+ && !!navigator.mediaDevices?.getUserMedia
15
+
16
+ async function startScanning(
17
+ elementId: string,
18
+ onResult: (code: string) => void
19
+ ): Promise<void> {
20
+ if (!isSupported.value) {
21
+ error.value = 'Camera scanning not supported in this browser'
22
+ return
23
+ }
24
+
25
+ try {
26
+ const { Html5Qrcode, Html5QrcodeSupportedFormats } = await import('html5-qrcode')
27
+
28
+ scanner = new Html5Qrcode(elementId)
29
+ isScanning.value = true
30
+ error.value = null
31
+
32
+ await scanner.start(
33
+ { facingMode: 'environment' },
34
+ {
35
+ fps: 10,
36
+ qrbox: { width: 250, height: 250 },
37
+ formatsToSupport: [
38
+ Html5QrcodeSupportedFormats.QR_CODE,
39
+ Html5QrcodeSupportedFormats.CODE_128,
40
+ Html5QrcodeSupportedFormats.CODE_39,
41
+ ],
42
+ },
43
+ (decodedText: string) => {
44
+ lastResult.value = decodedText
45
+ if (navigator.vibrate) navigator.vibrate(100)
46
+ onResult(decodedText)
47
+ },
48
+ () => {} // ignore "no code found" frames
49
+ )
50
+ } catch (err: any) {
51
+ error.value = err?.message || 'Camera access denied or not available'
52
+ isScanning.value = false
53
+ }
54
+ }
55
+
56
+ async function stopScanning(): Promise<void> {
57
+ if (scanner?.isScanning) {
58
+ await scanner.stop()
59
+ }
60
+ scanner = null
61
+ isScanning.value = false
62
+ }
63
+
64
+ async function toggleFlash(): Promise<void> {
65
+ flashOn.value = !flashOn.value
66
+ }
67
+
68
+ onUnmounted(() => {
69
+ stopScanning()
70
+ })
71
+
72
+ return {
73
+ isScanning,
74
+ lastResult,
75
+ error,
76
+ isSupported,
77
+ hasFlash,
78
+ flashOn,
79
+ startScanning,
80
+ stopScanning,
81
+ toggleFlash,
82
+ }
83
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module '*.vue' {
4
+ import type { DefineComponent } from 'vue'
5
+ const component: DefineComponent<{}, {}, any>
6
+ export default component
7
+ }
package/src/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ // Types
2
+ export type * from './types'
3
+
4
+ // Stores
5
+ export { useLocationStore } from './stores/locationStore'
6
+ export { useScannerStore } from './stores/scannerStore'
7
+ export { useCountStore } from './stores/countStore'
8
+
9
+ // Composables
10
+ export { useLocations } from './composables/useLocations'
11
+ export { useLocationProducts } from './composables/useLocationProducts'
12
+ export { useLocationSearch } from './composables/useLocationSearch'
13
+ export { useScanner } from './composables/useScanner'
14
+ export { useCountSessions } from './composables/useCountSessions'
15
+ export { useMovements } from './composables/useMovements'
16
+ export { useLabelPrinting } from './composables/useLabelPrinting'
17
+ export { useLocationBreadcrumbs } from './composables/useLocationBreadcrumbs'
18
+
19
+ // Page-level components (used in host app router)
20
+ export { default as InventoryDashboard } from './components/dashboard/InventoryDashboard.vue'
21
+ export { default as InventoryLocatorShell } from './components/locations/InventoryLocatorShell.vue'
22
+ export { default as LocationDetail } from './components/locations/LocationDetail.vue'
23
+ export { default as ScannerOverlay } from './components/scanning/ScannerOverlay.vue'
24
+ export { default as CountSessionList } from './components/counting/CountSessionList.vue'
25
+ export { default as CountSessionDetail } from './components/counting/CountSessionDetail.vue'
26
+ export { default as CountEntryForm } from './components/counting/CountEntryForm.vue'
27
+ export { default as CountReport } from './components/counting/CountReport.vue'
28
+ export { default as LabelBatchPrint } from './components/labels/LabelBatchPrint.vue'
29
+
30
+ // Embeddable components (used inside other host app views)
31
+ export { default as LocationTree } from './components/locations/LocationTree.vue'
32
+ export { default as LocationSearchInput } from './components/locations/LocationSearchInput.vue'
33
+ export { default as LocationCodeBadge } from './components/locations/LocationCodeBadge.vue'
34
+ export { default as LocationTypeIcon } from './components/locations/LocationTypeIcon.vue'
35
+ export { default as LocationBreadcrumb } from './components/locations/LocationBreadcrumb.vue'
36
+ export { default as ProductLocationList } from './components/products/ProductLocationList.vue'
37
+ export { default as ScanResult } from './components/scanning/ScanResult.vue'
38
+ export { default as ScanHistory } from './components/scanning/ScanHistory.vue'
39
+ export { default as LabelPreview } from './components/labels/LabelPreview.vue'
40
+ export { default as UnlocatedProducts } from './components/dashboard/UnlocatedProducts.vue'
41
+
42
+ // Plugin (optional global registration)
43
+ export { InventoryLocatorPlugin } from './plugin'
44
+
45
+ // API client configuration
46
+ export { configureApiClient } from './api/client'
package/src/plugin.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { App } from 'vue'
2
+ import { configureApiClient } from './api/client'
3
+
4
+ export interface InventoryLocatorOptions {
5
+ apiPrefix?: string
6
+ }
7
+
8
+ export const InventoryLocatorPlugin = {
9
+ install(app: App, options: InventoryLocatorOptions = {}) {
10
+ configureApiClient({
11
+ prefix: options.apiPrefix || '/api/inventory-locator/v1',
12
+ })
13
+ },
14
+ }
@@ -0,0 +1,95 @@
1
+ import { ref, computed } from 'vue'
2
+ import { defineStore } from 'pinia'
3
+
4
+ interface PendingCount {
5
+ sessionId: number
6
+ entryId: number
7
+ countedQuantity: number
8
+ notes?: string
9
+ queuedAt: string
10
+ }
11
+
12
+ export const useCountStore = defineStore('inventoryCount', () => {
13
+ const activeSessionId = ref<number | null>(null)
14
+ const currentLocationId = ref<number | null>(null)
15
+ const pendingSubmissions = ref<PendingCount[]>([])
16
+ const isOnline = ref(typeof navigator !== 'undefined' ? navigator.onLine : true)
17
+
18
+ const hasPendingSubmissions = computed(() => pendingSubmissions.value.length > 0)
19
+ const isCountingMode = computed(() => activeSessionId.value !== null)
20
+
21
+ function startCounting(sessionId: number) {
22
+ activeSessionId.value = sessionId
23
+ }
24
+
25
+ function stopCounting() {
26
+ activeSessionId.value = null
27
+ currentLocationId.value = null
28
+ }
29
+
30
+ function setCurrentLocation(locationId: number) {
31
+ currentLocationId.value = locationId
32
+ }
33
+
34
+ function queueCount(count: Omit<PendingCount, 'queuedAt'>) {
35
+ pendingSubmissions.value.push({
36
+ ...count,
37
+ queuedAt: new Date().toISOString(),
38
+ })
39
+ persistQueue()
40
+ }
41
+
42
+ function removeFromQueue(entryId: number) {
43
+ pendingSubmissions.value = pendingSubmissions.value.filter(
44
+ p => p.entryId !== entryId
45
+ )
46
+ persistQueue()
47
+ }
48
+
49
+ function persistQueue() {
50
+ sessionStorage.setItem(
51
+ 'inv-count-queue',
52
+ JSON.stringify(pendingSubmissions.value)
53
+ )
54
+ }
55
+
56
+ function loadQueue() {
57
+ const saved = sessionStorage.getItem('inv-count-queue')
58
+ if (saved) {
59
+ try {
60
+ pendingSubmissions.value = JSON.parse(saved)
61
+ } catch {
62
+ // ignore
63
+ }
64
+ }
65
+ }
66
+
67
+ async function syncPendingCounts(submitFn: (p: PendingCount) => Promise<void>) {
68
+ const queue = [...pendingSubmissions.value]
69
+ for (const pending of queue) {
70
+ try {
71
+ await submitFn(pending)
72
+ removeFromQueue(pending.entryId)
73
+ } catch {
74
+ break
75
+ }
76
+ }
77
+ }
78
+
79
+ return {
80
+ activeSessionId,
81
+ currentLocationId,
82
+ pendingSubmissions,
83
+ isOnline,
84
+ hasPendingSubmissions,
85
+ isCountingMode,
86
+ startCounting,
87
+ stopCounting,
88
+ setCurrentLocation,
89
+ queueCount,
90
+ removeFromQueue,
91
+ persistQueue,
92
+ loadQueue,
93
+ syncPendingCounts,
94
+ }
95
+ })
@@ -0,0 +1,113 @@
1
+ import { ref, computed } from 'vue'
2
+ import { defineStore } from 'pinia'
3
+ import type { Location } from '../types'
4
+
5
+ export const useLocationStore = defineStore('inventoryLocation', () => {
6
+ const roots = ref<Location[]>([])
7
+ const selectedLocationId = ref<number | null>(null)
8
+ const expandedNodeIds = ref(new Set<number>())
9
+ const childrenCache = ref(new Map<number, Location[]>())
10
+ const loading = ref(false)
11
+
12
+ function findInTree(id: number): Location | null {
13
+ const search = (nodes: Location[]): Location | null => {
14
+ for (const node of nodes) {
15
+ if (node.id === id) return node
16
+ if (node.children) {
17
+ const found = search(node.children)
18
+ if (found) return found
19
+ }
20
+ }
21
+ return null
22
+ }
23
+ return search(roots.value)
24
+ }
25
+
26
+ const selectedLocation = computed<Location | null>(() => {
27
+ if (!selectedLocationId.value) return null
28
+ return findInTree(selectedLocationId.value)
29
+ })
30
+
31
+ const isExpanded = computed(() => {
32
+ return (id: number) => expandedNodeIds.value.has(id)
33
+ })
34
+
35
+ function setRoots(locations: Location[]) {
36
+ roots.value = locations
37
+ }
38
+
39
+ function selectLocation(id: number | null) {
40
+ selectedLocationId.value = id
41
+ }
42
+
43
+ function toggleExpand(id: number) {
44
+ if (expandedNodeIds.value.has(id)) {
45
+ expandedNodeIds.value.delete(id)
46
+ } else {
47
+ expandedNodeIds.value.add(id)
48
+ }
49
+ }
50
+
51
+ function cacheChildren(parentId: number, children: Location[]) {
52
+ childrenCache.value.set(parentId, children)
53
+ }
54
+
55
+ function updateLocationInTree(updated: Location) {
56
+ const replace = (nodes: Location[]): boolean => {
57
+ for (let i = 0; i < nodes.length; i++) {
58
+ if (nodes[i].id === updated.id) {
59
+ nodes[i] = { ...nodes[i], ...updated }
60
+ return true
61
+ }
62
+ if (nodes[i].children && replace(nodes[i].children!)) {
63
+ return true
64
+ }
65
+ }
66
+ return false
67
+ }
68
+ replace(roots.value)
69
+ }
70
+
71
+ function removeLocationFromTree(id: number) {
72
+ const remove = (nodes: Location[]): boolean => {
73
+ const idx = nodes.findIndex(n => n.id === id)
74
+ if (idx !== -1) {
75
+ nodes.splice(idx, 1)
76
+ return true
77
+ }
78
+ for (const node of nodes) {
79
+ if (node.children && remove(node.children)) return true
80
+ }
81
+ return false
82
+ }
83
+ remove(roots.value)
84
+ if (selectedLocationId.value === id) {
85
+ selectedLocationId.value = null
86
+ }
87
+ }
88
+
89
+ function reset() {
90
+ roots.value = []
91
+ selectedLocationId.value = null
92
+ expandedNodeIds.value.clear()
93
+ childrenCache.value.clear()
94
+ }
95
+
96
+ return {
97
+ roots,
98
+ selectedLocationId,
99
+ expandedNodeIds,
100
+ childrenCache,
101
+ loading,
102
+ selectedLocation,
103
+ isExpanded,
104
+ setRoots,
105
+ selectLocation,
106
+ toggleExpand,
107
+ cacheChildren,
108
+ updateLocationInTree,
109
+ removeLocationFromTree,
110
+ findInTree,
111
+ reset,
112
+ }
113
+ })
@@ -0,0 +1,51 @@
1
+ import { ref } from 'vue'
2
+ import { defineStore } from 'pinia'
3
+
4
+ interface ScanHistoryEntry {
5
+ code: string
6
+ locationName: string
7
+ locationId: number
8
+ scannedAt: string
9
+ }
10
+
11
+ export const useScannerStore = defineStore('inventoryScanner', () => {
12
+ const history = ref<ScanHistoryEntry[]>([])
13
+ const lastScannedCode = ref<string | null>(null)
14
+
15
+ function addToHistory(entry: Omit<ScanHistoryEntry, 'scannedAt'>) {
16
+ history.value.unshift({
17
+ ...entry,
18
+ scannedAt: new Date().toISOString(),
19
+ })
20
+ if (history.value.length > 20) {
21
+ history.value = history.value.slice(0, 20)
22
+ }
23
+ lastScannedCode.value = entry.code
24
+ sessionStorage.setItem('inv-scan-history', JSON.stringify(history.value))
25
+ }
26
+
27
+ function loadFromSession() {
28
+ const saved = sessionStorage.getItem('inv-scan-history')
29
+ if (saved) {
30
+ try {
31
+ history.value = JSON.parse(saved)
32
+ } catch {
33
+ // ignore corrupt data
34
+ }
35
+ }
36
+ }
37
+
38
+ function clearHistory() {
39
+ history.value = []
40
+ lastScannedCode.value = null
41
+ sessionStorage.removeItem('inv-scan-history')
42
+ }
43
+
44
+ return {
45
+ history,
46
+ lastScannedCode,
47
+ addToHistory,
48
+ loadFromSession,
49
+ clearHistory,
50
+ }
51
+ })