@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,637 @@
1
+ <template>
2
+ <InvModal
3
+ :show="show"
4
+ title="Add Product to Location"
5
+ size="md"
6
+ initial-focus-selector=".lpa-search-input"
7
+ @update:show="handleClose"
8
+ >
9
+ <!-- Step 1: Search for product -->
10
+ <div v-if="step === 1" class="lpa-step">
11
+ <div class="lpa-search">
12
+ <label for="lpa-search-field" class="lpa-label">Search products</label>
13
+ <div class="lpa-search-wrapper">
14
+ <svg class="lpa-search-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
15
+ <circle cx="7" cy="7" r="5" stroke="currentColor" stroke-width="1.5" />
16
+ <path d="M11 11L14 14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
17
+ </svg>
18
+ <input
19
+ id="lpa-search-field"
20
+ ref="searchInputRef"
21
+ v-model="searchQuery"
22
+ type="search"
23
+ class="lpa-search-input"
24
+ placeholder="Search by name or SKU..."
25
+ autocomplete="off"
26
+ :aria-expanded="searchResults.length > 0"
27
+ aria-controls="lpa-search-results"
28
+ @input="handleSearch"
29
+ />
30
+ <button
31
+ v-if="searchQuery"
32
+ type="button"
33
+ class="lpa-search-clear"
34
+ aria-label="Clear search"
35
+ @click="clearSearch"
36
+ >
37
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
38
+ <path d="M10.5 3.5L3.5 10.5M3.5 3.5L10.5 10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
39
+ </svg>
40
+ </button>
41
+ </div>
42
+ </div>
43
+
44
+ <div
45
+ v-if="searching"
46
+ class="lpa-loading"
47
+ role="status"
48
+ aria-live="polite"
49
+ >
50
+ <div class="lpa-spinner" aria-hidden="true" />
51
+ <span>Searching...</span>
52
+ </div>
53
+
54
+ <ul
55
+ v-else-if="searchResults.length > 0"
56
+ id="lpa-search-results"
57
+ class="lpa-results"
58
+ role="listbox"
59
+ aria-label="Search results"
60
+ >
61
+ <li
62
+ v-for="result in searchResults"
63
+ :key="result.id"
64
+ class="lpa-result-item"
65
+ role="option"
66
+ :aria-selected="false"
67
+ tabindex="0"
68
+ @click="selectProduct(result)"
69
+ @keydown.enter="selectProduct(result)"
70
+ @keydown.space.prevent="selectProduct(result)"
71
+ >
72
+ <div class="lpa-result-info">
73
+ <span class="lpa-result-name">{{ result.name }}</span>
74
+ <span v-if="result.sku" class="lpa-result-sku">{{ result.sku }}</span>
75
+ </div>
76
+ <svg class="lpa-result-arrow" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
77
+ <path d="M6 4L10 8L6 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
78
+ </svg>
79
+ </li>
80
+ </ul>
81
+
82
+ <div
83
+ v-else-if="searchQuery.length >= 2 && !searching"
84
+ class="lpa-no-results"
85
+ role="status"
86
+ >
87
+ <p class="lpa-no-results-text">No products found for "{{ searchQuery }}"</p>
88
+ </div>
89
+
90
+ <p v-else class="lpa-hint">
91
+ Type at least 2 characters to search.
92
+ </p>
93
+ </div>
94
+
95
+ <!-- Step 2: Set quantity + options -->
96
+ <div v-else class="lpa-step">
97
+ <button
98
+ type="button"
99
+ class="lpa-back-btn"
100
+ @click="goBackToSearch"
101
+ >
102
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
103
+ <path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
104
+ </svg>
105
+ Back to search
106
+ </button>
107
+
108
+ <div class="lpa-selected">
109
+ <span class="lpa-selected-label">Selected product</span>
110
+ <div class="lpa-selected-product">
111
+ <span class="lpa-selected-name">{{ selectedProduct?.name }}</span>
112
+ <span v-if="selectedProduct?.sku" class="lpa-selected-sku">{{ selectedProduct.sku }}</span>
113
+ </div>
114
+ </div>
115
+
116
+ <div class="lpa-form">
117
+ <InvInput
118
+ v-model="form.quantity"
119
+ label="Quantity"
120
+ type="number"
121
+ placeholder="1.00"
122
+ :error="formErrors.quantity"
123
+ />
124
+
125
+ <div class="lpa-checkbox-field">
126
+ <label class="lpa-checkbox-label">
127
+ <input
128
+ v-model="form.isPrimary"
129
+ type="checkbox"
130
+ class="lpa-checkbox"
131
+ />
132
+ Set as primary location
133
+ </label>
134
+ </div>
135
+
136
+ <div class="lpa-textarea-field">
137
+ <label for="lpa-notes" class="lpa-label">Notes (optional)</label>
138
+ <textarea
139
+ id="lpa-notes"
140
+ v-model="form.notes"
141
+ class="lpa-textarea"
142
+ placeholder="Optional notes about this placement..."
143
+ rows="3"
144
+ />
145
+ </div>
146
+ </div>
147
+
148
+ <p v-if="submitError" class="lpa-error" role="alert">{{ submitError }}</p>
149
+ </div>
150
+
151
+ <template #footer>
152
+ <InvButton variant="secondary" @click="handleClose">
153
+ Cancel
154
+ </InvButton>
155
+ <InvButton
156
+ v-if="step === 2"
157
+ variant="primary"
158
+ :loading="submitting"
159
+ :disabled="!canSubmit"
160
+ @click="handleSubmit"
161
+ >
162
+ Add Product
163
+ </InvButton>
164
+ </template>
165
+ </InvModal>
166
+ </template>
167
+
168
+ <script setup lang="ts">
169
+ import { ref, computed, watch, nextTick } from 'vue'
170
+ import type { LocationProduct } from '../../types'
171
+ import { useLocationProducts } from '../../composables/useLocationProducts'
172
+ import { getApiClient, apiUrl } from '../../api/client'
173
+ import InvModal from '../shared/InvModal.vue'
174
+ import InvInput from '../shared/InvInput.vue'
175
+ import InvButton from '../shared/InvButton.vue'
176
+
177
+ interface ProductSearchResult {
178
+ id: number | string
179
+ name: string
180
+ sku: string | null
181
+ thumbnail?: string | null
182
+ }
183
+
184
+ const props = defineProps<{
185
+ locationId: number
186
+ show: boolean
187
+ }>()
188
+
189
+ const emit = defineEmits<{
190
+ 'update:show': [value: boolean]
191
+ added: [lp: LocationProduct]
192
+ }>()
193
+
194
+ const { addProduct } = useLocationProducts()
195
+
196
+ // Step management
197
+ const step = ref<1 | 2>(1)
198
+
199
+ // Search state
200
+ const searchQuery = ref('')
201
+ const searchResults = ref<ProductSearchResult[]>([])
202
+ const searching = ref(false)
203
+ const searchInputRef = ref<HTMLInputElement | null>(null)
204
+ let searchDebounce: ReturnType<typeof setTimeout> | null = null
205
+
206
+ // Selected product
207
+ const selectedProduct = ref<ProductSearchResult | null>(null)
208
+
209
+ // Form state
210
+ const form = ref({
211
+ quantity: 1 as number,
212
+ isPrimary: false,
213
+ notes: '',
214
+ })
215
+
216
+ const formErrors = ref<{ quantity?: string }>({})
217
+ const submitError = ref<string | null>(null)
218
+ const submitting = ref(false)
219
+
220
+ const canSubmit = computed(() => {
221
+ return (
222
+ selectedProduct.value !== null &&
223
+ form.value.quantity > 0 &&
224
+ !submitting.value
225
+ )
226
+ })
227
+
228
+ function handleSearch() {
229
+ const q = searchQuery.value.trim()
230
+
231
+ if (searchDebounce) clearTimeout(searchDebounce)
232
+
233
+ if (q.length < 2) {
234
+ searchResults.value = []
235
+ return
236
+ }
237
+
238
+ searchDebounce = setTimeout(async () => {
239
+ searching.value = true
240
+ try {
241
+ const response = await getApiClient().get(apiUrl('/products/search'), {
242
+ params: { q },
243
+ })
244
+ searchResults.value = response.data.data
245
+ } catch {
246
+ searchResults.value = []
247
+ } finally {
248
+ searching.value = false
249
+ }
250
+ }, 300)
251
+ }
252
+
253
+ function clearSearch() {
254
+ searchQuery.value = ''
255
+ searchResults.value = []
256
+ nextTick(() => {
257
+ searchInputRef.value?.focus()
258
+ })
259
+ }
260
+
261
+ function selectProduct(product: ProductSearchResult) {
262
+ selectedProduct.value = product
263
+ step.value = 2
264
+ formErrors.value = {}
265
+ submitError.value = null
266
+ }
267
+
268
+ function goBackToSearch() {
269
+ step.value = 1
270
+ selectedProduct.value = null
271
+ form.value = { quantity: 1, isPrimary: false, notes: '' }
272
+ formErrors.value = {}
273
+ submitError.value = null
274
+ }
275
+
276
+ function handleClose() {
277
+ emit('update:show', false)
278
+ }
279
+
280
+ async function handleSubmit() {
281
+ if (!selectedProduct.value) return
282
+
283
+ formErrors.value = {}
284
+ submitError.value = null
285
+
286
+ if (!form.value.quantity || form.value.quantity < 0.01) {
287
+ formErrors.value.quantity = 'Quantity must be at least 0.01'
288
+ return
289
+ }
290
+
291
+ submitting.value = true
292
+
293
+ try {
294
+ const result = await addProduct(props.locationId, {
295
+ product_id: selectedProduct.value.id,
296
+ quantity: form.value.quantity,
297
+ is_primary: form.value.isPrimary,
298
+ notes: form.value.notes || undefined,
299
+ })
300
+ emit('added', result)
301
+ handleClose()
302
+ } catch (err: any) {
303
+ submitError.value = err.response?.data?.message || 'Failed to add product'
304
+ } finally {
305
+ submitting.value = false
306
+ }
307
+ }
308
+
309
+ // Reset state when modal opens/closes
310
+ watch(
311
+ () => props.show,
312
+ (visible) => {
313
+ if (visible) {
314
+ step.value = 1
315
+ searchQuery.value = ''
316
+ searchResults.value = []
317
+ selectedProduct.value = null
318
+ form.value = { quantity: 1, isPrimary: false, notes: '' }
319
+ formErrors.value = {}
320
+ submitError.value = null
321
+ } else {
322
+ if (searchDebounce) clearTimeout(searchDebounce)
323
+ }
324
+ },
325
+ )
326
+ </script>
327
+
328
+ <style scoped>
329
+ .lpa-step {
330
+ display: flex;
331
+ flex-direction: column;
332
+ gap: var(--space-4, 1rem);
333
+ }
334
+
335
+ /* Search */
336
+ .lpa-search {
337
+ display: flex;
338
+ flex-direction: column;
339
+ gap: var(--space-1, 0.25rem);
340
+ }
341
+
342
+ .lpa-label {
343
+ font-size: var(--text-sm, 0.875rem);
344
+ font-weight: 500;
345
+ color: var(--admin-text-primary);
346
+ line-height: 1.4;
347
+ }
348
+
349
+ .lpa-search-wrapper {
350
+ position: relative;
351
+ display: flex;
352
+ align-items: center;
353
+ }
354
+
355
+ .lpa-search-icon {
356
+ position: absolute;
357
+ left: var(--space-3, 0.75rem);
358
+ color: var(--admin-text-tertiary);
359
+ pointer-events: none;
360
+ }
361
+
362
+ .lpa-search-input {
363
+ width: 100%;
364
+ height: 44px;
365
+ padding: 0 var(--space-8, 2rem) 0 var(--space-8, 2rem);
366
+ border: 1px solid var(--admin-border);
367
+ border-radius: 6px;
368
+ background: var(--admin-card-bg);
369
+ color: var(--admin-text-primary);
370
+ font-size: var(--text-sm, 0.875rem);
371
+ outline: none;
372
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
373
+ }
374
+
375
+ .lpa-search-input::placeholder {
376
+ color: var(--admin-text-tertiary);
377
+ }
378
+
379
+ .lpa-search-input:focus {
380
+ border-color: var(--color-primary, #2563eb);
381
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
382
+ }
383
+
384
+ .lpa-search-clear {
385
+ position: absolute;
386
+ right: var(--space-2, 0.5rem);
387
+ display: flex;
388
+ align-items: center;
389
+ justify-content: center;
390
+ width: 32px;
391
+ height: 32px;
392
+ padding: 0;
393
+ border: none;
394
+ border-radius: 4px;
395
+ background: transparent;
396
+ color: var(--admin-text-tertiary);
397
+ cursor: pointer;
398
+ }
399
+
400
+ .lpa-search-clear:hover {
401
+ color: var(--admin-text-primary);
402
+ background: var(--admin-content-bg);
403
+ }
404
+
405
+ .lpa-search-clear:focus-visible {
406
+ outline: 2px solid var(--admin-focus-ring, #2563eb);
407
+ outline-offset: 2px;
408
+ }
409
+
410
+ /* Loading */
411
+ .lpa-loading {
412
+ display: flex;
413
+ align-items: center;
414
+ gap: var(--space-2, 0.5rem);
415
+ padding: var(--space-3, 0.75rem);
416
+ font-size: var(--text-sm, 0.875rem);
417
+ color: var(--admin-text-secondary);
418
+ }
419
+
420
+ .lpa-spinner {
421
+ width: 18px;
422
+ height: 18px;
423
+ border: 2px solid var(--admin-border);
424
+ border-top-color: var(--color-primary, #2563eb);
425
+ border-radius: 50%;
426
+ animation: lpa-spin 0.6s linear infinite;
427
+ }
428
+
429
+ @keyframes lpa-spin {
430
+ to { transform: rotate(360deg); }
431
+ }
432
+
433
+ /* Results */
434
+ .lpa-results {
435
+ list-style: none;
436
+ margin: 0;
437
+ padding: 0;
438
+ border: 1px solid var(--admin-border);
439
+ border-radius: 6px;
440
+ max-height: 280px;
441
+ overflow-y: auto;
442
+ }
443
+
444
+ .lpa-result-item {
445
+ display: flex;
446
+ align-items: center;
447
+ justify-content: space-between;
448
+ gap: var(--space-3, 0.75rem);
449
+ padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
450
+ min-height: 44px;
451
+ cursor: pointer;
452
+ transition: background-color 0.1s ease;
453
+ }
454
+
455
+ .lpa-result-item:not(:last-child) {
456
+ border-bottom: 1px solid var(--admin-border);
457
+ }
458
+
459
+ .lpa-result-item:hover {
460
+ background: var(--admin-content-bg);
461
+ }
462
+
463
+ .lpa-result-item:focus-visible {
464
+ outline: 2px solid var(--admin-focus-ring, #2563eb);
465
+ outline-offset: -2px;
466
+ }
467
+
468
+ .lpa-result-info {
469
+ display: flex;
470
+ flex-direction: column;
471
+ gap: 2px;
472
+ min-width: 0;
473
+ }
474
+
475
+ .lpa-result-name {
476
+ font-size: var(--text-sm, 0.875rem);
477
+ font-weight: 500;
478
+ color: var(--admin-text-primary);
479
+ white-space: nowrap;
480
+ overflow: hidden;
481
+ text-overflow: ellipsis;
482
+ }
483
+
484
+ .lpa-result-sku {
485
+ font-size: var(--text-xs, 0.75rem);
486
+ color: var(--admin-text-tertiary);
487
+ }
488
+
489
+ .lpa-result-arrow {
490
+ flex-shrink: 0;
491
+ color: var(--admin-text-tertiary);
492
+ }
493
+
494
+ .lpa-no-results {
495
+ padding: var(--space-4, 1rem);
496
+ text-align: center;
497
+ }
498
+
499
+ .lpa-no-results-text {
500
+ margin: 0;
501
+ font-size: var(--text-sm, 0.875rem);
502
+ color: var(--admin-text-tertiary);
503
+ }
504
+
505
+ .lpa-hint {
506
+ margin: 0;
507
+ font-size: var(--text-sm, 0.875rem);
508
+ color: var(--admin-text-tertiary);
509
+ text-align: center;
510
+ padding: var(--space-4, 1rem);
511
+ }
512
+
513
+ /* Back button */
514
+ .lpa-back-btn {
515
+ display: inline-flex;
516
+ align-items: center;
517
+ gap: var(--space-1, 0.25rem);
518
+ padding: 0;
519
+ border: none;
520
+ background: transparent;
521
+ color: var(--color-primary, #2563eb);
522
+ font-size: var(--text-sm, 0.875rem);
523
+ font-weight: 500;
524
+ cursor: pointer;
525
+ border-radius: 4px;
526
+ align-self: flex-start;
527
+ }
528
+
529
+ .lpa-back-btn:hover {
530
+ text-decoration: underline;
531
+ }
532
+
533
+ .lpa-back-btn:focus-visible {
534
+ outline: 2px solid var(--admin-focus-ring, #2563eb);
535
+ outline-offset: 2px;
536
+ }
537
+
538
+ /* Selected product display */
539
+ .lpa-selected {
540
+ display: flex;
541
+ flex-direction: column;
542
+ gap: var(--space-1, 0.25rem);
543
+ padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
544
+ border: 1px solid var(--admin-border);
545
+ border-radius: 6px;
546
+ background: var(--admin-content-bg);
547
+ }
548
+
549
+ .lpa-selected-label {
550
+ font-size: var(--text-xs, 0.75rem);
551
+ font-weight: 500;
552
+ color: var(--admin-text-tertiary);
553
+ text-transform: uppercase;
554
+ letter-spacing: 0.05em;
555
+ }
556
+
557
+ .lpa-selected-product {
558
+ display: flex;
559
+ flex-direction: column;
560
+ gap: 2px;
561
+ }
562
+
563
+ .lpa-selected-name {
564
+ font-size: var(--text-sm, 0.875rem);
565
+ font-weight: 600;
566
+ color: var(--admin-text-primary);
567
+ }
568
+
569
+ .lpa-selected-sku {
570
+ font-size: var(--text-xs, 0.75rem);
571
+ color: var(--admin-text-secondary);
572
+ }
573
+
574
+ /* Form fields */
575
+ .lpa-form {
576
+ display: flex;
577
+ flex-direction: column;
578
+ gap: var(--space-4, 1rem);
579
+ }
580
+
581
+ .lpa-checkbox-field {
582
+ display: flex;
583
+ align-items: center;
584
+ }
585
+
586
+ .lpa-checkbox-label {
587
+ display: flex;
588
+ align-items: center;
589
+ gap: var(--space-2, 0.5rem);
590
+ font-size: var(--text-sm, 0.875rem);
591
+ color: var(--admin-text-primary);
592
+ cursor: pointer;
593
+ }
594
+
595
+ .lpa-checkbox {
596
+ width: 18px;
597
+ height: 18px;
598
+ accent-color: var(--color-primary, #2563eb);
599
+ cursor: pointer;
600
+ }
601
+
602
+ .lpa-textarea-field {
603
+ display: flex;
604
+ flex-direction: column;
605
+ gap: var(--space-1, 0.25rem);
606
+ }
607
+
608
+ .lpa-textarea {
609
+ width: 100%;
610
+ padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
611
+ border: 1px solid var(--admin-border);
612
+ border-radius: 6px;
613
+ background: var(--admin-card-bg);
614
+ color: var(--admin-text-primary);
615
+ font-size: var(--text-sm, 0.875rem);
616
+ font-family: inherit;
617
+ resize: vertical;
618
+ outline: none;
619
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
620
+ }
621
+
622
+ .lpa-textarea::placeholder {
623
+ color: var(--admin-text-tertiary);
624
+ }
625
+
626
+ .lpa-textarea:focus {
627
+ border-color: var(--color-primary, #2563eb);
628
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
629
+ }
630
+
631
+ .lpa-error {
632
+ margin: 0;
633
+ font-size: var(--text-sm, 0.875rem);
634
+ color: var(--color-error, #dc2626);
635
+ line-height: 1.4;
636
+ }
637
+ </style>