@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,428 @@
1
+ <template>
2
+ <div
3
+ class="location-search"
4
+ :class="{ 'location-search--open': showDropdown }"
5
+ >
6
+ <div class="location-search__input-wrapper">
7
+ <svg
8
+ class="location-search__icon"
9
+ width="18"
10
+ height="18"
11
+ viewBox="0 0 24 24"
12
+ fill="none"
13
+ aria-hidden="true"
14
+ >
15
+ <path
16
+ d="M21 21L16.65 16.65M19 11C19 15.4183 15.4183 19 11 19C6.58172 19 3 15.4183 3 11C3 6.58172 6.58172 3 11 3C15.4183 3 19 6.58172 19 11Z"
17
+ stroke="currentColor"
18
+ stroke-width="2"
19
+ stroke-linecap="round"
20
+ stroke-linejoin="round"
21
+ />
22
+ </svg>
23
+ <input
24
+ ref="inputRef"
25
+ type="search"
26
+ class="location-search__input"
27
+ :value="modelValue"
28
+ :placeholder="placeholder"
29
+ role="combobox"
30
+ aria-autocomplete="list"
31
+ :aria-expanded="showDropdown"
32
+ :aria-controls="listboxId"
33
+ :aria-activedescendant="activeDescendantId"
34
+ @input="handleInput"
35
+ @keydown="handleKeydown"
36
+ @focus="handleFocus"
37
+ @blur="handleBlur"
38
+ />
39
+ <button
40
+ v-if="modelValue"
41
+ type="button"
42
+ class="location-search__clear"
43
+ aria-label="Clear search"
44
+ @mousedown.prevent="handleClear"
45
+ >
46
+ <svg
47
+ width="16"
48
+ height="16"
49
+ viewBox="0 0 16 16"
50
+ fill="none"
51
+ aria-hidden="true"
52
+ >
53
+ <path
54
+ d="M12 4L4 12M4 4L12 12"
55
+ stroke="currentColor"
56
+ stroke-width="2"
57
+ stroke-linecap="round"
58
+ stroke-linejoin="round"
59
+ />
60
+ </svg>
61
+ </button>
62
+ <span
63
+ v-if="searchComposable.loading.value"
64
+ class="location-search__spinner"
65
+ aria-hidden="true"
66
+ >
67
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
68
+ <circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="2" opacity="0.25" />
69
+ <path d="M14 8A6 6 0 0 0 8 2" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
70
+ </svg>
71
+ </span>
72
+ </div>
73
+
74
+ <Transition name="location-search-dropdown">
75
+ <ul
76
+ v-if="showDropdown"
77
+ :id="listboxId"
78
+ ref="listboxRef"
79
+ class="location-search__dropdown"
80
+ role="listbox"
81
+ :aria-label="placeholder"
82
+ >
83
+ <li
84
+ v-if="searchComposable.loading.value && searchComposable.results.value.length === 0"
85
+ class="location-search__dropdown-item location-search__dropdown-item--loading"
86
+ role="presentation"
87
+ >
88
+ <span>Searching...</span>
89
+ </li>
90
+ <li
91
+ v-else-if="!searchComposable.loading.value && searchComposable.results.value.length === 0 && modelValue.length >= 2"
92
+ class="location-search__dropdown-item location-search__dropdown-item--empty"
93
+ role="presentation"
94
+ >
95
+ <span>No locations found</span>
96
+ </li>
97
+ <li
98
+ v-for="(location, index) in searchComposable.results.value"
99
+ :id="`${listboxId}-option-${index}`"
100
+ :key="location.id"
101
+ class="location-search__dropdown-item"
102
+ :class="{ 'location-search__dropdown-item--active': index === activeIndex }"
103
+ role="option"
104
+ :aria-selected="index === activeIndex"
105
+ @mousedown.prevent="selectLocation(location)"
106
+ @mouseenter="activeIndex = index"
107
+ >
108
+ <LocationTypeIcon :type="location.type" size="sm" />
109
+ <div class="location-search__result-info">
110
+ <span class="location-search__result-name">{{ location.name }}</span>
111
+ <span class="location-search__result-code">{{ location.full_code }}</span>
112
+ </div>
113
+ </li>
114
+ </ul>
115
+ </Transition>
116
+ </div>
117
+ </template>
118
+
119
+ <script setup lang="ts">
120
+ import { ref, computed, onMounted, useId, watch } from 'vue'
121
+ import type { Location } from '../../types'
122
+ import { useLocationSearch } from '../../composables/useLocationSearch'
123
+ import LocationTypeIcon from './LocationTypeIcon.vue'
124
+
125
+ const props = withDefaults(defineProps<{
126
+ modelValue: string
127
+ placeholder?: string
128
+ autofocus?: boolean
129
+ }>(), {
130
+ placeholder: 'Search locations...',
131
+ autofocus: false,
132
+ })
133
+
134
+ const emit = defineEmits<{
135
+ 'update:modelValue': [value: string]
136
+ select: [location: Location]
137
+ }>()
138
+
139
+ const uid = useId()
140
+ const listboxId = `location-search-listbox-${uid}`
141
+
142
+ const inputRef = ref<HTMLInputElement | null>(null)
143
+ const listboxRef = ref<HTMLUListElement | null>(null)
144
+ const activeIndex = ref(-1)
145
+ const isFocused = ref(false)
146
+
147
+ const searchComposable = useLocationSearch()
148
+
149
+ const showDropdown = computed(() => {
150
+ if (!isFocused.value) return false
151
+ if (props.modelValue.length < 2) return false
152
+ if (searchComposable.loading.value) return true
153
+ return searchComposable.results.value.length > 0 || props.modelValue.length >= 2
154
+ })
155
+
156
+ const activeDescendantId = computed(() => {
157
+ if (activeIndex.value < 0) return undefined
158
+ return `${listboxId}-option-${activeIndex.value}`
159
+ })
160
+
161
+ watch(() => props.modelValue, (val) => {
162
+ activeIndex.value = -1
163
+ searchComposable.search(val)
164
+ })
165
+
166
+ function handleInput(event: Event) {
167
+ const target = event.target as HTMLInputElement
168
+ emit('update:modelValue', target.value)
169
+ }
170
+
171
+ function handleFocus() {
172
+ isFocused.value = true
173
+ if (props.modelValue.length >= 2) {
174
+ searchComposable.search(props.modelValue)
175
+ }
176
+ }
177
+
178
+ function handleBlur() {
179
+ // Delay to allow mousedown on dropdown to fire first
180
+ setTimeout(() => {
181
+ isFocused.value = false
182
+ activeIndex.value = -1
183
+ }, 150)
184
+ }
185
+
186
+ function handleClear() {
187
+ emit('update:modelValue', '')
188
+ searchComposable.clear()
189
+ activeIndex.value = -1
190
+ inputRef.value?.focus()
191
+ }
192
+
193
+ function selectLocation(location: Location) {
194
+ emit('select', location)
195
+ emit('update:modelValue', '')
196
+ searchComposable.clear()
197
+ activeIndex.value = -1
198
+ isFocused.value = false
199
+ }
200
+
201
+ function handleKeydown(event: KeyboardEvent) {
202
+ const results = searchComposable.results.value
203
+ if (!showDropdown.value || results.length === 0) {
204
+ if (event.key === 'Escape') {
205
+ handleClear()
206
+ }
207
+ return
208
+ }
209
+
210
+ switch (event.key) {
211
+ case 'ArrowDown':
212
+ event.preventDefault()
213
+ activeIndex.value = Math.min(activeIndex.value + 1, results.length - 1)
214
+ scrollActiveIntoView()
215
+ break
216
+ case 'ArrowUp':
217
+ event.preventDefault()
218
+ activeIndex.value = Math.max(activeIndex.value - 1, 0)
219
+ scrollActiveIntoView()
220
+ break
221
+ case 'Enter':
222
+ event.preventDefault()
223
+ if (activeIndex.value >= 0 && activeIndex.value < results.length) {
224
+ selectLocation(results[activeIndex.value])
225
+ }
226
+ break
227
+ case 'Escape':
228
+ event.preventDefault()
229
+ isFocused.value = false
230
+ activeIndex.value = -1
231
+ break
232
+ }
233
+ }
234
+
235
+ function scrollActiveIntoView() {
236
+ if (!listboxRef.value || activeIndex.value < 0) return
237
+ const items = listboxRef.value.querySelectorAll('[role="option"]')
238
+ const activeItem = items[activeIndex.value] as HTMLElement | undefined
239
+ if (activeItem) {
240
+ activeItem.scrollIntoView({ block: 'nearest' })
241
+ }
242
+ }
243
+
244
+ onMounted(() => {
245
+ if (props.autofocus && inputRef.value) {
246
+ inputRef.value.focus()
247
+ }
248
+ })
249
+ </script>
250
+
251
+ <style scoped>
252
+ .location-search {
253
+ position: relative;
254
+ width: 100%;
255
+ }
256
+
257
+ .location-search__input-wrapper {
258
+ position: relative;
259
+ display: flex;
260
+ align-items: center;
261
+ }
262
+
263
+ .location-search__icon {
264
+ position: absolute;
265
+ left: var(--space-3, 0.75rem);
266
+ color: var(--admin-text-tertiary);
267
+ pointer-events: none;
268
+ flex-shrink: 0;
269
+ }
270
+
271
+ .location-search__input {
272
+ width: 100%;
273
+ height: 44px;
274
+ padding: 0 var(--space-8, 2rem) 0 calc(var(--space-3, 0.75rem) + 18px + var(--space-2, 0.5rem));
275
+ border: 1px solid var(--admin-border);
276
+ border-radius: 6px;
277
+ background: var(--admin-card-bg);
278
+ color: var(--admin-text-primary);
279
+ font-size: var(--text-sm, 0.875rem);
280
+ line-height: 1;
281
+ outline: none;
282
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
283
+ }
284
+
285
+ .location-search__input::placeholder {
286
+ color: var(--admin-text-tertiary);
287
+ }
288
+
289
+ .location-search__input:focus {
290
+ border-color: var(--color-primary, #2563eb);
291
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
292
+ }
293
+
294
+ /* Hide native search cancel button */
295
+ .location-search__input::-webkit-search-cancel-button {
296
+ display: none;
297
+ }
298
+
299
+ .location-search__clear {
300
+ position: absolute;
301
+ right: var(--space-2, 0.5rem);
302
+ display: flex;
303
+ align-items: center;
304
+ justify-content: center;
305
+ width: 28px;
306
+ height: 28px;
307
+ padding: 0;
308
+ border: none;
309
+ border-radius: 4px;
310
+ background: transparent;
311
+ color: var(--admin-text-tertiary);
312
+ cursor: pointer;
313
+ transition: background-color 0.15s ease, color 0.15s ease;
314
+ }
315
+
316
+ .location-search__clear:hover {
317
+ background: var(--admin-content-bg);
318
+ color: var(--admin-text-primary);
319
+ }
320
+
321
+ .location-search__clear:focus-visible {
322
+ outline: 2px solid var(--admin-focus-ring, #2563eb);
323
+ outline-offset: 2px;
324
+ }
325
+
326
+ .location-search__spinner {
327
+ position: absolute;
328
+ right: var(--space-3, 0.75rem);
329
+ display: flex;
330
+ align-items: center;
331
+ color: var(--admin-text-tertiary);
332
+ animation: location-search-spin 0.8s linear infinite;
333
+ }
334
+
335
+ .location-search__input-wrapper:has(.location-search__clear) .location-search__spinner {
336
+ right: calc(var(--space-2, 0.5rem) + 28px + var(--space-1, 0.25rem));
337
+ }
338
+
339
+ @keyframes location-search-spin {
340
+ from { transform: rotate(0deg); }
341
+ to { transform: rotate(360deg); }
342
+ }
343
+
344
+ /* Dropdown */
345
+ .location-search__dropdown {
346
+ position: absolute;
347
+ top: calc(100% + 4px);
348
+ left: 0;
349
+ right: 0;
350
+ z-index: 100;
351
+ max-height: 280px;
352
+ overflow-y: auto;
353
+ margin: 0;
354
+ padding: var(--space-1, 0.25rem) 0;
355
+ list-style: none;
356
+ background: var(--admin-card-bg);
357
+ border: 1px solid var(--admin-border);
358
+ border-radius: 8px;
359
+ box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1));
360
+ }
361
+
362
+ .location-search__dropdown-item {
363
+ display: flex;
364
+ align-items: center;
365
+ gap: var(--space-3, 0.75rem);
366
+ padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
367
+ cursor: pointer;
368
+ transition: background-color 0.1s ease;
369
+ color: var(--admin-text-primary);
370
+ }
371
+
372
+ .location-search__dropdown-item:hover,
373
+ .location-search__dropdown-item--active {
374
+ background: var(--admin-table-row-hover);
375
+ }
376
+
377
+ .location-search__dropdown-item--loading,
378
+ .location-search__dropdown-item--empty {
379
+ cursor: default;
380
+ color: var(--admin-text-tertiary);
381
+ font-size: var(--text-sm, 0.875rem);
382
+ font-style: italic;
383
+ justify-content: center;
384
+ padding: var(--space-3, 0.75rem);
385
+ }
386
+
387
+ .location-search__dropdown-item--loading:hover,
388
+ .location-search__dropdown-item--empty:hover {
389
+ background: transparent;
390
+ }
391
+
392
+ .location-search__result-info {
393
+ display: flex;
394
+ flex-direction: column;
395
+ gap: 1px;
396
+ min-width: 0;
397
+ }
398
+
399
+ .location-search__result-name {
400
+ font-size: var(--text-sm, 0.875rem);
401
+ font-weight: 500;
402
+ color: var(--admin-text-primary);
403
+ white-space: nowrap;
404
+ overflow: hidden;
405
+ text-overflow: ellipsis;
406
+ }
407
+
408
+ .location-search__result-code {
409
+ font-size: var(--text-xs, 0.75rem);
410
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
411
+ color: var(--admin-text-tertiary);
412
+ white-space: nowrap;
413
+ overflow: hidden;
414
+ text-overflow: ellipsis;
415
+ }
416
+
417
+ /* Dropdown transition */
418
+ .location-search-dropdown-enter-active,
419
+ .location-search-dropdown-leave-active {
420
+ transition: opacity 0.15s ease, transform 0.15s ease;
421
+ }
422
+
423
+ .location-search-dropdown-enter-from,
424
+ .location-search-dropdown-leave-to {
425
+ opacity: 0;
426
+ transform: translateY(-4px);
427
+ }
428
+ </style>
@@ -0,0 +1,156 @@
1
+ <template>
2
+ <div class="location-tree" role="tree" aria-label="Location hierarchy">
3
+ <!-- Loading state -->
4
+ <div v-if="loading" class="location-tree__loading" aria-live="polite">
5
+ <div
6
+ v-for="n in 5"
7
+ :key="n"
8
+ class="location-tree__skeleton-row"
9
+ :style="{ paddingLeft: `${(n % 3) * 20}px` }"
10
+ >
11
+ <div class="location-tree__skeleton-bar location-tree__skeleton-bar--icon" />
12
+ <div class="location-tree__skeleton-bar location-tree__skeleton-bar--name" />
13
+ <div class="location-tree__skeleton-bar location-tree__skeleton-bar--code" />
14
+ </div>
15
+ <span class="sr-only">Loading locations...</span>
16
+ </div>
17
+
18
+ <!-- Empty state -->
19
+ <InvEmptyState
20
+ v-else-if="locationStore.roots.length === 0"
21
+ title="No locations yet"
22
+ description="Create your first warehouse or storage location to start organizing inventory."
23
+ action-label="Create Location"
24
+ @action="emit('create')"
25
+ />
26
+
27
+ <!-- Tree nodes -->
28
+ <div v-else>
29
+ <LocationTreeNode
30
+ v-for="root in locationStore.roots"
31
+ :key="root.id"
32
+ :location="root"
33
+ :depth="0"
34
+ :selected-id="locationStore.selectedLocationId"
35
+ @select="handleSelect"
36
+ @toggle="handleToggle"
37
+ />
38
+ </div>
39
+ </div>
40
+ </template>
41
+
42
+ <script setup lang="ts">
43
+ import { onMounted, ref } from 'vue'
44
+ import type { Location } from '../../types'
45
+ import { useLocationStore } from '../../stores/locationStore'
46
+ import { useLocations } from '../../composables/useLocations'
47
+ import InvEmptyState from '../shared/InvEmptyState.vue'
48
+ import LocationTreeNode from './LocationTreeNode.vue'
49
+
50
+ const emit = defineEmits<{
51
+ select: [location: Location]
52
+ create: []
53
+ }>()
54
+
55
+ const locationStore = useLocationStore()
56
+ const { fetchRoots } = useLocations()
57
+
58
+ const loading = ref(false)
59
+
60
+ async function loadRoots() {
61
+ if (locationStore.roots.length > 0) return
62
+ loading.value = true
63
+ try {
64
+ await fetchRoots()
65
+ } catch {
66
+ // Error handled by composable
67
+ } finally {
68
+ loading.value = false
69
+ }
70
+ }
71
+
72
+ function handleSelect(location: Location) {
73
+ locationStore.selectLocation(location.id)
74
+ emit('select', location)
75
+ }
76
+
77
+ function handleToggle(_id: number) {
78
+ // Toggle is handled inside LocationTreeNode via locationStore
79
+ }
80
+
81
+ onMounted(() => {
82
+ loadRoots()
83
+ })
84
+ </script>
85
+
86
+ <style scoped>
87
+ .location-tree {
88
+ padding: var(--space-2, 0.5rem);
89
+ overflow-y: auto;
90
+ flex: 1;
91
+ min-height: 0;
92
+ }
93
+
94
+ .location-tree__loading {
95
+ display: flex;
96
+ flex-direction: column;
97
+ gap: var(--space-2, 0.5rem);
98
+ padding: var(--space-2, 0.5rem);
99
+ }
100
+
101
+ .location-tree__skeleton-row {
102
+ display: flex;
103
+ align-items: center;
104
+ gap: var(--space-2, 0.5rem);
105
+ padding: var(--space-1, 0.25rem) var(--space-3, 0.75rem);
106
+ min-height: 36px;
107
+ }
108
+
109
+ .location-tree__skeleton-bar {
110
+ border-radius: 4px;
111
+ background: linear-gradient(
112
+ 90deg,
113
+ var(--admin-border) 25%,
114
+ var(--admin-content-bg) 50%,
115
+ var(--admin-border) 75%
116
+ );
117
+ background-size: 200% 100%;
118
+ animation: tree-shimmer 1.5s ease-in-out infinite;
119
+ }
120
+
121
+ .location-tree__skeleton-bar--icon {
122
+ width: 16px;
123
+ height: 16px;
124
+ flex-shrink: 0;
125
+ border-radius: 50%;
126
+ }
127
+
128
+ .location-tree__skeleton-bar--name {
129
+ width: 100px;
130
+ height: 14px;
131
+ flex-shrink: 0;
132
+ }
133
+
134
+ .location-tree__skeleton-bar--code {
135
+ width: 60px;
136
+ height: 14px;
137
+ flex-shrink: 0;
138
+ }
139
+
140
+ @keyframes tree-shimmer {
141
+ 0% { background-position: 200% 0; }
142
+ 100% { background-position: -200% 0; }
143
+ }
144
+
145
+ .sr-only {
146
+ position: absolute;
147
+ width: 1px;
148
+ height: 1px;
149
+ padding: 0;
150
+ margin: -1px;
151
+ overflow: hidden;
152
+ clip: rect(0, 0, 0, 0);
153
+ white-space: nowrap;
154
+ border-width: 0;
155
+ }
156
+ </style>