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