@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.
- package/package.json +47 -0
- package/src/api/client.ts +31 -0
- package/src/components/counting/CountEntryForm.vue +833 -0
- package/src/components/counting/CountReport.vue +385 -0
- package/src/components/counting/CountSessionDetail.vue +650 -0
- package/src/components/counting/CountSessionList.vue +683 -0
- package/src/components/dashboard/InventoryDashboard.vue +670 -0
- package/src/components/dashboard/UnlocatedProducts.vue +468 -0
- package/src/components/labels/LabelBatchPrint.vue +528 -0
- package/src/components/labels/LabelPreview.vue +293 -0
- package/src/components/locations/InventoryLocatorShell.vue +408 -0
- package/src/components/locations/LocationBreadcrumb.vue +144 -0
- package/src/components/locations/LocationCodeBadge.vue +46 -0
- package/src/components/locations/LocationDetail.vue +884 -0
- package/src/components/locations/LocationForm.vue +360 -0
- package/src/components/locations/LocationSearchInput.vue +428 -0
- package/src/components/locations/LocationTree.vue +156 -0
- package/src/components/locations/LocationTreeNode.vue +280 -0
- package/src/components/locations/LocationTypeIcon.vue +58 -0
- package/src/components/products/LocationProductAdd.vue +637 -0
- package/src/components/products/LocationProductList.vue +547 -0
- package/src/components/products/ProductLocationList.vue +215 -0
- package/src/components/products/QuickMoveModal.vue +592 -0
- package/src/components/scanning/ScanHistory.vue +146 -0
- package/src/components/scanning/ScanResult.vue +350 -0
- package/src/components/scanning/ScannerOverlay.vue +696 -0
- package/src/components/shared/InvBadge.vue +71 -0
- package/src/components/shared/InvButton.vue +206 -0
- package/src/components/shared/InvCard.vue +254 -0
- package/src/components/shared/InvEmptyState.vue +132 -0
- package/src/components/shared/InvInput.vue +125 -0
- package/src/components/shared/InvModal.vue +296 -0
- package/src/components/shared/InvSelect.vue +155 -0
- package/src/components/shared/InvTable.vue +288 -0
- package/src/composables/useCountSessions.ts +184 -0
- package/src/composables/useLabelPrinting.ts +71 -0
- package/src/composables/useLocationBreadcrumbs.ts +19 -0
- package/src/composables/useLocationProducts.ts +125 -0
- package/src/composables/useLocationSearch.ts +46 -0
- package/src/composables/useLocations.ts +159 -0
- package/src/composables/useMovements.ts +71 -0
- package/src/composables/useScanner.ts +83 -0
- package/src/env.d.ts +7 -0
- package/src/index.ts +46 -0
- package/src/plugin.ts +14 -0
- package/src/stores/countStore.ts +95 -0
- package/src/stores/locationStore.ts +113 -0
- package/src/stores/scannerStore.ts +51 -0
- package/src/types/index.ts +216 -0
- package/src/utils/codeFormatter.ts +29 -0
- package/src/utils/locationIcons.ts +64 -0
- package/tsconfig.json +21 -0
- 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
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
|
+
})
|