@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,547 @@
1
+ <template>
2
+ <InvCard :title="title" :loading="loading" no-padding>
3
+ <template #actions>
4
+ <InvButton size="sm" @click="emit('add-product')">
5
+ <template #icon-left>
6
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
7
+ <path d="M8 3V13M3 8H13" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
8
+ </svg>
9
+ </template>
10
+ Add Product
11
+ </InvButton>
12
+ </template>
13
+
14
+ <template v-if="!loading && products.length === 0">
15
+ <InvEmptyState
16
+ title="No products at this location"
17
+ description="Add products to track their inventory at this location."
18
+ action-label="Add Product"
19
+ @action="emit('add-product')"
20
+ />
21
+ </template>
22
+
23
+ <template v-else-if="!loading">
24
+ <InvTable
25
+ :columns="columns"
26
+ :data="products"
27
+ empty-message="No products at this location."
28
+ >
29
+ <template #cell-product_name="{ row }">
30
+ <div class="lpl-product-info">
31
+ <span class="lpl-product-name">{{ row.product_name }}</span>
32
+ <span v-if="row.product_sku" class="lpl-product-sku">{{ row.product_sku }}</span>
33
+ </div>
34
+ </template>
35
+
36
+ <template #cell-quantity="{ row }">
37
+ <div class="lpl-quantity-cell">
38
+ <template v-if="editingProductId === row.product_id">
39
+ <input
40
+ ref="quantityInputRef"
41
+ type="number"
42
+ class="lpl-quantity-input"
43
+ :value="editQuantity"
44
+ step="0.01"
45
+ min="0"
46
+ aria-label="Edit quantity"
47
+ @input="handleQuantityInput"
48
+ @keydown.enter="saveQuantity(row)"
49
+ @keydown.escape="cancelEdit"
50
+ @blur="saveQuantity(row)"
51
+ />
52
+ </template>
53
+ <template v-else>
54
+ <button
55
+ type="button"
56
+ class="lpl-quantity-display"
57
+ :aria-label="`Edit quantity for ${row.product_name}, currently ${formatQuantity(row.quantity)}`"
58
+ @click="startEdit(row)"
59
+ >
60
+ {{ formatQuantity(row.quantity) }}
61
+ </button>
62
+ </template>
63
+ </div>
64
+ </template>
65
+
66
+ <template #cell-is_primary="{ row }">
67
+ <button
68
+ type="button"
69
+ class="lpl-star-btn"
70
+ :class="{ 'lpl-star-btn--active': row.is_primary }"
71
+ :aria-label="row.is_primary ? `Unset ${row.product_name} primary location` : `Set ${row.product_name} as primary location`"
72
+ :aria-pressed="row.is_primary"
73
+ @click="togglePrimary(row)"
74
+ >
75
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
76
+ <path
77
+ d="M9 1.5L11.3 6.2L16.5 6.9L12.75 10.55L13.6 15.75L9 13.3L4.4 15.75L5.25 10.55L1.5 6.9L6.7 6.2L9 1.5Z"
78
+ :fill="row.is_primary ? 'currentColor' : 'none'"
79
+ stroke="currentColor"
80
+ stroke-width="1.5"
81
+ stroke-linecap="round"
82
+ stroke-linejoin="round"
83
+ />
84
+ </svg>
85
+ </button>
86
+ </template>
87
+
88
+ <template #cell-actions="{ row }">
89
+ <div class="lpl-actions">
90
+ <button
91
+ type="button"
92
+ class="lpl-action-btn lpl-action-btn--move"
93
+ :aria-label="`Move ${row.product_name}`"
94
+ @click="emit('move-product', row)"
95
+ >
96
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
97
+ <path d="M3 9H15M15 9L11 5M15 9L11 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
98
+ </svg>
99
+ </button>
100
+ <button
101
+ type="button"
102
+ class="lpl-action-btn lpl-action-btn--remove"
103
+ :aria-label="`Remove ${row.product_name}`"
104
+ @click="showRemoveConfirmation(row)"
105
+ >
106
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
107
+ <path d="M2.25 4.5H15.75M6 4.5V3C6 2.17 6.67 1.5 7.5 1.5H10.5C11.33 1.5 12 2.17 12 3V4.5M14.25 4.5V15C14.25 15.83 13.58 16.5 12.75 16.5H5.25C4.42 16.5 3.75 15.83 3.75 15V4.5H14.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
108
+ </svg>
109
+ </button>
110
+ </div>
111
+ </template>
112
+ </InvTable>
113
+
114
+ <div v-if="hasMore" class="lpl-load-more">
115
+ <InvButton variant="ghost" size="sm" :loading="loadingMore" @click="loadMore">
116
+ Load more
117
+ </InvButton>
118
+ </div>
119
+ </template>
120
+ </InvCard>
121
+
122
+ <!-- Remove confirmation dialog -->
123
+ <InvModal
124
+ :show="removeConfirm.show"
125
+ :title="`Remove ${removeConfirm.product?.product_name ?? 'Product'}`"
126
+ size="sm"
127
+ @update:show="closeRemoveConfirmation"
128
+ >
129
+ <div class="lpl-remove-dialog">
130
+ <p class="lpl-remove-message">
131
+ Remove
132
+ <strong>{{ removeConfirm.product?.product_name }}</strong>
133
+ from this location?
134
+ </p>
135
+
136
+ <div class="lpl-remove-quantity">
137
+ <label for="remove-qty-toggle" class="lpl-remove-label">
138
+ <input
139
+ id="remove-qty-toggle"
140
+ v-model="removeConfirm.partial"
141
+ type="checkbox"
142
+ class="lpl-remove-checkbox"
143
+ />
144
+ Remove partial quantity only
145
+ </label>
146
+
147
+ <div v-if="removeConfirm.partial" class="lpl-remove-qty-input">
148
+ <label for="remove-qty-amount" class="lpl-remove-qty-label">
149
+ Quantity to remove (max {{ formatQuantity(removeConfirm.product?.quantity ?? 0) }})
150
+ </label>
151
+ <input
152
+ id="remove-qty-amount"
153
+ v-model.number="removeConfirm.quantity"
154
+ type="number"
155
+ class="lpl-quantity-input lpl-quantity-input--wide"
156
+ step="0.01"
157
+ min="0.01"
158
+ :max="removeConfirm.product?.quantity"
159
+ />
160
+ </div>
161
+ </div>
162
+
163
+ <p v-if="removeError" class="lpl-remove-error" role="alert">{{ removeError }}</p>
164
+ </div>
165
+
166
+ <template #footer>
167
+ <InvButton variant="secondary" @click="closeRemoveConfirmation">
168
+ Cancel
169
+ </InvButton>
170
+ <InvButton
171
+ variant="danger"
172
+ :loading="removing"
173
+ :disabled="removeConfirm.partial && (!removeConfirm.quantity || removeConfirm.quantity <= 0)"
174
+ @click="confirmRemove"
175
+ >
176
+ Remove
177
+ </InvButton>
178
+ </template>
179
+ </InvModal>
180
+ </template>
181
+
182
+ <script setup lang="ts">
183
+ import { ref, computed, nextTick, watch } from 'vue'
184
+ import type { LocationProduct, Column } from '../../types'
185
+ import { useLocationProducts } from '../../composables/useLocationProducts'
186
+ import InvCard from '../shared/InvCard.vue'
187
+ import InvTable from '../shared/InvTable.vue'
188
+ import InvButton from '../shared/InvButton.vue'
189
+ import InvEmptyState from '../shared/InvEmptyState.vue'
190
+ import InvModal from '../shared/InvModal.vue'
191
+
192
+ const props = defineProps<{
193
+ locationId: number
194
+ }>()
195
+
196
+ const emit = defineEmits<{
197
+ 'add-product': []
198
+ 'move-product': [lp: LocationProduct]
199
+ 'remove-product': [lp: LocationProduct]
200
+ }>()
201
+
202
+ const { products, loading, error, fetchProducts, updateProduct, removeProduct } = useLocationProducts()
203
+
204
+ const title = computed(() => 'Products')
205
+
206
+ const columns: Column[] = [
207
+ { key: 'product_name', label: 'Product', sortable: false },
208
+ { key: 'quantity', label: 'Qty', sortable: false, width: '100px', align: 'right' },
209
+ { key: 'is_primary', label: 'Primary', sortable: false, width: '80px', align: 'center' },
210
+ { key: 'actions', label: 'Actions', sortable: false, width: '100px', align: 'center' },
211
+ ]
212
+
213
+ // Pagination
214
+ const page = ref(1)
215
+ const perPage = 50
216
+ const hasMore = ref(false)
217
+ const loadingMore = ref(false)
218
+
219
+ // Inline quantity editing
220
+ const editingProductId = ref<number | string | null>(null)
221
+ const editQuantity = ref<number>(0)
222
+ const originalQuantity = ref<number>(0)
223
+ const quantityInputRef = ref<HTMLInputElement | null>(null)
224
+
225
+ // Remove confirmation
226
+ const removeConfirm = ref<{
227
+ show: boolean
228
+ product: LocationProduct | null
229
+ partial: boolean
230
+ quantity: number
231
+ }>({
232
+ show: false,
233
+ product: null,
234
+ partial: false,
235
+ quantity: 0,
236
+ })
237
+ const removing = ref(false)
238
+ const removeError = ref<string | null>(null)
239
+
240
+ function formatQuantity(qty: number): string {
241
+ return Number.isInteger(qty) ? qty.toFixed(0) : qty.toFixed(2)
242
+ }
243
+
244
+ function startEdit(row: LocationProduct) {
245
+ editingProductId.value = row.product_id
246
+ editQuantity.value = row.quantity
247
+ originalQuantity.value = row.quantity
248
+ nextTick(() => {
249
+ if (quantityInputRef.value) {
250
+ quantityInputRef.value.focus()
251
+ quantityInputRef.value.select()
252
+ }
253
+ })
254
+ }
255
+
256
+ function handleQuantityInput(event: Event) {
257
+ const target = event.target as HTMLInputElement
258
+ editQuantity.value = target.valueAsNumber
259
+ }
260
+
261
+ async function saveQuantity(row: LocationProduct) {
262
+ const newQty = editQuantity.value
263
+ editingProductId.value = null
264
+
265
+ if (isNaN(newQty) || newQty === originalQuantity.value) {
266
+ return
267
+ }
268
+
269
+ try {
270
+ await updateProduct(props.locationId, row.product_id, { quantity: newQty })
271
+ } catch {
272
+ // Revert on failure - re-fetch products
273
+ await fetchProducts(props.locationId)
274
+ }
275
+ }
276
+
277
+ function cancelEdit() {
278
+ editingProductId.value = null
279
+ }
280
+
281
+ async function togglePrimary(row: LocationProduct) {
282
+ try {
283
+ await updateProduct(props.locationId, row.product_id, { is_primary: !row.is_primary })
284
+ } catch {
285
+ // error already set by composable
286
+ }
287
+ }
288
+
289
+ function showRemoveConfirmation(row: LocationProduct) {
290
+ removeConfirm.value = {
291
+ show: true,
292
+ product: row,
293
+ partial: false,
294
+ quantity: row.quantity,
295
+ }
296
+ removeError.value = null
297
+ }
298
+
299
+ function closeRemoveConfirmation() {
300
+ removeConfirm.value = {
301
+ show: false,
302
+ product: null,
303
+ partial: false,
304
+ quantity: 0,
305
+ }
306
+ removeError.value = null
307
+ }
308
+
309
+ async function confirmRemove() {
310
+ const product = removeConfirm.value.product
311
+ if (!product) return
312
+
313
+ removing.value = true
314
+ removeError.value = null
315
+
316
+ try {
317
+ const quantity = removeConfirm.value.partial ? removeConfirm.value.quantity : undefined
318
+ await removeProduct(props.locationId, product.product_id, quantity)
319
+ emit('remove-product', product)
320
+ closeRemoveConfirmation()
321
+ } catch (err: any) {
322
+ removeError.value = err.response?.data?.message || 'Failed to remove product'
323
+ } finally {
324
+ removing.value = false
325
+ }
326
+ }
327
+
328
+ async function loadMore() {
329
+ loadingMore.value = true
330
+ page.value += 1
331
+ // In practice this would pass page param; for now fetchProducts loads all
332
+ loadingMore.value = false
333
+ }
334
+
335
+ // Initial load
336
+ watch(
337
+ () => props.locationId,
338
+ (id) => {
339
+ if (id) {
340
+ page.value = 1
341
+ fetchProducts(id)
342
+ }
343
+ },
344
+ { immediate: true },
345
+ )
346
+ </script>
347
+
348
+ <style scoped>
349
+ .lpl-product-info {
350
+ display: flex;
351
+ flex-direction: column;
352
+ gap: 2px;
353
+ }
354
+
355
+ .lpl-product-name {
356
+ font-weight: 500;
357
+ color: var(--admin-text-primary);
358
+ line-height: 1.3;
359
+ }
360
+
361
+ .lpl-product-sku {
362
+ font-size: var(--text-xs, 0.75rem);
363
+ color: var(--admin-text-tertiary);
364
+ line-height: 1.3;
365
+ }
366
+
367
+ .lpl-quantity-cell {
368
+ display: flex;
369
+ justify-content: flex-end;
370
+ }
371
+
372
+ .lpl-quantity-display {
373
+ display: inline-flex;
374
+ align-items: center;
375
+ justify-content: flex-end;
376
+ min-width: 56px;
377
+ min-height: 32px;
378
+ padding: var(--space-1, 0.25rem) var(--space-2, 0.5rem);
379
+ border: 1px solid transparent;
380
+ border-radius: 4px;
381
+ background: transparent;
382
+ color: var(--admin-text-primary);
383
+ font-size: var(--text-sm, 0.875rem);
384
+ font-family: inherit;
385
+ cursor: pointer;
386
+ transition: border-color 0.15s ease, background-color 0.15s ease;
387
+ }
388
+
389
+ .lpl-quantity-display:hover {
390
+ border-color: var(--admin-border);
391
+ background: var(--admin-content-bg);
392
+ }
393
+
394
+ .lpl-quantity-display:focus-visible {
395
+ outline: 2px solid var(--admin-focus-ring, #2563eb);
396
+ outline-offset: 2px;
397
+ }
398
+
399
+ .lpl-quantity-input {
400
+ width: 80px;
401
+ height: 32px;
402
+ padding: 0 var(--space-2, 0.5rem);
403
+ border: 1px solid var(--color-primary, #2563eb);
404
+ border-radius: 4px;
405
+ background: var(--admin-card-bg);
406
+ color: var(--admin-text-primary);
407
+ font-size: var(--text-sm, 0.875rem);
408
+ font-family: inherit;
409
+ text-align: right;
410
+ outline: none;
411
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
412
+ }
413
+
414
+ .lpl-quantity-input--wide {
415
+ width: 100%;
416
+ height: 44px;
417
+ }
418
+
419
+ .lpl-star-btn {
420
+ display: inline-flex;
421
+ align-items: center;
422
+ justify-content: center;
423
+ width: 44px;
424
+ height: 44px;
425
+ padding: 0;
426
+ border: none;
427
+ border-radius: 6px;
428
+ background: transparent;
429
+ color: var(--admin-text-tertiary);
430
+ cursor: pointer;
431
+ transition: color 0.15s ease, background-color 0.15s ease;
432
+ }
433
+
434
+ .lpl-star-btn:hover {
435
+ background: var(--admin-content-bg);
436
+ color: var(--color-warning, #d97706);
437
+ }
438
+
439
+ .lpl-star-btn--active {
440
+ color: var(--color-warning, #d97706);
441
+ }
442
+
443
+ .lpl-star-btn:focus-visible {
444
+ outline: 2px solid var(--admin-focus-ring, #2563eb);
445
+ outline-offset: 2px;
446
+ }
447
+
448
+ .lpl-actions {
449
+ display: flex;
450
+ align-items: center;
451
+ justify-content: center;
452
+ gap: var(--space-1, 0.25rem);
453
+ }
454
+
455
+ .lpl-action-btn {
456
+ display: inline-flex;
457
+ align-items: center;
458
+ justify-content: center;
459
+ width: 44px;
460
+ height: 44px;
461
+ padding: 0;
462
+ border: none;
463
+ border-radius: 6px;
464
+ background: transparent;
465
+ color: var(--admin-text-secondary);
466
+ cursor: pointer;
467
+ transition: color 0.15s ease, background-color 0.15s ease;
468
+ }
469
+
470
+ .lpl-action-btn:hover {
471
+ background: var(--admin-content-bg);
472
+ }
473
+
474
+ .lpl-action-btn--move:hover {
475
+ color: var(--color-primary, #2563eb);
476
+ }
477
+
478
+ .lpl-action-btn--remove:hover {
479
+ color: var(--color-error, #dc2626);
480
+ }
481
+
482
+ .lpl-action-btn:focus-visible {
483
+ outline: 2px solid var(--admin-focus-ring, #2563eb);
484
+ outline-offset: 2px;
485
+ }
486
+
487
+ .lpl-load-more {
488
+ display: flex;
489
+ justify-content: center;
490
+ padding: var(--space-3, 0.75rem);
491
+ border-top: 1px solid var(--admin-border);
492
+ }
493
+
494
+ /* Remove confirmation dialog */
495
+ .lpl-remove-dialog {
496
+ display: flex;
497
+ flex-direction: column;
498
+ gap: var(--space-4, 1rem);
499
+ }
500
+
501
+ .lpl-remove-message {
502
+ margin: 0;
503
+ font-size: var(--text-sm, 0.875rem);
504
+ color: var(--admin-text-primary);
505
+ line-height: 1.5;
506
+ }
507
+
508
+ .lpl-remove-quantity {
509
+ display: flex;
510
+ flex-direction: column;
511
+ gap: var(--space-3, 0.75rem);
512
+ }
513
+
514
+ .lpl-remove-label {
515
+ display: flex;
516
+ align-items: center;
517
+ gap: var(--space-2, 0.5rem);
518
+ font-size: var(--text-sm, 0.875rem);
519
+ color: var(--admin-text-primary);
520
+ cursor: pointer;
521
+ }
522
+
523
+ .lpl-remove-checkbox {
524
+ width: 18px;
525
+ height: 18px;
526
+ accent-color: var(--color-primary, #2563eb);
527
+ cursor: pointer;
528
+ }
529
+
530
+ .lpl-remove-qty-input {
531
+ display: flex;
532
+ flex-direction: column;
533
+ gap: var(--space-1, 0.25rem);
534
+ }
535
+
536
+ .lpl-remove-qty-label {
537
+ font-size: var(--text-xs, 0.75rem);
538
+ color: var(--admin-text-secondary);
539
+ }
540
+
541
+ .lpl-remove-error {
542
+ margin: 0;
543
+ font-size: var(--text-sm, 0.875rem);
544
+ color: var(--color-error, #dc2626);
545
+ line-height: 1.4;
546
+ }
547
+ </style>