@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,468 @@
1
+ <template>
2
+ <div class="unlocated-products">
3
+ <div class="unlocated-products__header">
4
+ <div>
5
+ <h2 class="unlocated-products__title">Unlocated Products</h2>
6
+ <p v-if="totalCount > 0" class="unlocated-products__subtitle">
7
+ {{ totalCount }} product{{ totalCount !== 1 ? 's' : '' }} have no assigned warehouse location
8
+ </p>
9
+ </div>
10
+ </div>
11
+
12
+ <!-- Loading -->
13
+ <InvCard v-if="loading && products.length === 0" loading />
14
+
15
+ <!-- Table -->
16
+ <InvCard v-else-if="products.length > 0" no-padding>
17
+ <InvTable
18
+ :columns="columns"
19
+ :data="products"
20
+ empty-message="All products have assigned locations."
21
+ >
22
+ <template #cell-name="{ row }">
23
+ <span class="unlocated-products__product-name">{{ row.name }}</span>
24
+ </template>
25
+ <template #cell-sku="{ row }">
26
+ <span v-if="row.sku" class="unlocated-products__sku">{{ row.sku }}</span>
27
+ <span v-else class="unlocated-products__no-sku">no SKU</span>
28
+ </template>
29
+ <template #cell-actions="{ row }">
30
+ <InvButton
31
+ variant="primary"
32
+ size="sm"
33
+ @click="handleLocate(row)"
34
+ >
35
+ Locate
36
+ </InvButton>
37
+ </template>
38
+ </InvTable>
39
+
40
+ <!-- Load more -->
41
+ <div v-if="hasMore" class="unlocated-products__load-more">
42
+ <p class="unlocated-products__showing">
43
+ Showing {{ products.length }} of {{ totalCount }}
44
+ </p>
45
+ <InvButton
46
+ variant="ghost"
47
+ size="sm"
48
+ :loading="loadingMore"
49
+ @click="loadMore"
50
+ >
51
+ Load More
52
+ </InvButton>
53
+ </div>
54
+ </InvCard>
55
+
56
+ <!-- Empty -->
57
+ <InvEmptyState
58
+ v-else
59
+ title="All products located"
60
+ description="Every product in inventory has an assigned warehouse location."
61
+ />
62
+
63
+ <!-- Locate modal -->
64
+ <InvModal
65
+ :show="showLocateModal"
66
+ :title="`Locate: ${locateProduct?.name ?? ''}`"
67
+ description="Search for a location to assign this product."
68
+ size="md"
69
+ @update:show="showLocateModal = $event"
70
+ @close="closeLocateModal"
71
+ >
72
+ <div class="unlocated-products__locate-form">
73
+ <div class="unlocated-products__locate-search">
74
+ <label for="locate-search-input" class="unlocated-products__locate-label">
75
+ Location
76
+ </label>
77
+ <input
78
+ id="locate-search-input"
79
+ v-model="locateSearchQuery"
80
+ type="search"
81
+ class="unlocated-products__locate-input"
82
+ placeholder="Search by code or name..."
83
+ @input="handleLocateSearch"
84
+ />
85
+ </div>
86
+
87
+ <!-- Search results -->
88
+ <ul
89
+ v-if="locateSearchResults.length > 0"
90
+ class="unlocated-products__locate-results"
91
+ role="listbox"
92
+ aria-label="Location search results"
93
+ >
94
+ <li
95
+ v-for="loc in locateSearchResults"
96
+ :key="loc.id"
97
+ role="option"
98
+ :aria-selected="selectedLocation?.id === loc.id"
99
+ >
100
+ <button
101
+ type="button"
102
+ class="unlocated-products__locate-option"
103
+ :class="{ 'unlocated-products__locate-option--selected': selectedLocation?.id === loc.id }"
104
+ @click="selectLocateLocation(loc)"
105
+ >
106
+ <LocationCodeBadge :code="loc.full_code" />
107
+ <span class="unlocated-products__locate-option-name">{{ loc.name }}</span>
108
+ <InvBadge variant="muted" size="sm">{{ loc.type_label }}</InvBadge>
109
+ </button>
110
+ </li>
111
+ </ul>
112
+
113
+ <p
114
+ v-if="locateSearchQuery.length >= 2 && locateSearchResults.length === 0 && !searchLoading"
115
+ class="unlocated-products__locate-no-results"
116
+ >
117
+ No locations found
118
+ </p>
119
+
120
+ <!-- Selected location display -->
121
+ <div v-if="selectedLocation" class="unlocated-products__locate-selected">
122
+ <span class="unlocated-products__locate-selected-label">Assign to:</span>
123
+ <LocationCodeBadge :code="selectedLocation.full_code" />
124
+ <span>{{ selectedLocation.name }}</span>
125
+ </div>
126
+ </div>
127
+
128
+ <template #footer>
129
+ <InvButton variant="ghost" size="sm" @click="closeLocateModal">
130
+ Cancel
131
+ </InvButton>
132
+ <InvButton
133
+ variant="primary"
134
+ size="sm"
135
+ :disabled="!selectedLocation"
136
+ :loading="assigning"
137
+ @click="handleAssign"
138
+ >
139
+ Assign Location
140
+ </InvButton>
141
+ </template>
142
+ </InvModal>
143
+ </div>
144
+ </template>
145
+
146
+ <script setup lang="ts">
147
+ import { ref, computed, onMounted } from 'vue'
148
+ import { getApiClient, apiUrl } from '../../api/client'
149
+ import { useLocationSearch } from '../../composables/useLocationSearch'
150
+ import { useLocationProducts } from '../../composables/useLocationProducts'
151
+ import type { Location, Column } from '../../types'
152
+ import InvCard from '../shared/InvCard.vue'
153
+ import InvButton from '../shared/InvButton.vue'
154
+ import InvTable from '../shared/InvTable.vue'
155
+ import InvBadge from '../shared/InvBadge.vue'
156
+ import InvModal from '../shared/InvModal.vue'
157
+ import InvEmptyState from '../shared/InvEmptyState.vue'
158
+ import LocationCodeBadge from '../locations/LocationCodeBadge.vue'
159
+
160
+ interface UnlocatedProduct {
161
+ id: number | string
162
+ name: string
163
+ sku: string | null
164
+ }
165
+
166
+ const props = withDefaults(defineProps<{
167
+ limit?: number
168
+ }>(), {
169
+ limit: 50,
170
+ })
171
+
172
+ const emit = defineEmits<{
173
+ locate: [product: { id: number | string; name: string }]
174
+ }>()
175
+
176
+ const locationSearchComposable = useLocationSearch()
177
+ const { addProduct } = useLocationProducts()
178
+
179
+ const products = ref<UnlocatedProduct[]>([])
180
+ const totalCount = ref(0)
181
+ const loading = ref(false)
182
+ const loadingMore = ref(false)
183
+ const page = ref(1)
184
+
185
+ // Locate modal state
186
+ const showLocateModal = ref(false)
187
+ const locateProduct = ref<UnlocatedProduct | null>(null)
188
+ const locateSearchQuery = ref('')
189
+ const locateSearchResults = ref<Location[]>([])
190
+ const selectedLocation = ref<Location | null>(null)
191
+ const searchLoading = ref(false)
192
+ const assigning = ref(false)
193
+
194
+ const columns: Column[] = [
195
+ { key: 'name', label: 'Product' },
196
+ { key: 'sku', label: 'SKU', width: '150px' },
197
+ { key: 'actions', label: '', width: '100px', align: 'right' },
198
+ ]
199
+
200
+ const hasMore = computed(() => products.value.length < totalCount.value)
201
+
202
+ onMounted(() => fetchProducts())
203
+
204
+ async function fetchProducts(append = false) {
205
+ if (append) {
206
+ loadingMore.value = true
207
+ } else {
208
+ loading.value = true
209
+ }
210
+
211
+ try {
212
+ const response = await getApiClient().get(
213
+ apiUrl(`/dashboard/unlocated-products?limit=${props.limit}&page=${page.value}`)
214
+ )
215
+ const data = response.data.data || response.data
216
+ totalCount.value = response.data.meta?.total ?? data.length
217
+
218
+ if (append) {
219
+ products.value.push(...data)
220
+ } else {
221
+ products.value = data
222
+ }
223
+ } catch {
224
+ // Degrade gracefully
225
+ } finally {
226
+ loading.value = false
227
+ loadingMore.value = false
228
+ }
229
+ }
230
+
231
+ function loadMore() {
232
+ page.value++
233
+ fetchProducts(true)
234
+ }
235
+
236
+ function handleLocate(product: UnlocatedProduct) {
237
+ locateProduct.value = product
238
+ showLocateModal.value = true
239
+ locateSearchQuery.value = ''
240
+ locateSearchResults.value = []
241
+ selectedLocation.value = null
242
+ emit('locate', { id: product.id, name: product.name })
243
+ }
244
+
245
+ function handleLocateSearch() {
246
+ searchLoading.value = true
247
+ locationSearchComposable.search(locateSearchQuery.value)
248
+
249
+ // Watch for results (debounced in composable)
250
+ const checkResults = setInterval(() => {
251
+ if (!locationSearchComposable.loading.value) {
252
+ locateSearchResults.value = locationSearchComposable.results.value
253
+ searchLoading.value = false
254
+ clearInterval(checkResults)
255
+ }
256
+ }, 100)
257
+
258
+ // Safety timeout
259
+ setTimeout(() => {
260
+ clearInterval(checkResults)
261
+ searchLoading.value = false
262
+ locateSearchResults.value = locationSearchComposable.results.value
263
+ }, 2000)
264
+ }
265
+
266
+ function selectLocateLocation(location: Location) {
267
+ selectedLocation.value = location
268
+ locateSearchResults.value = []
269
+ locateSearchQuery.value = ''
270
+ }
271
+
272
+ function closeLocateModal() {
273
+ showLocateModal.value = false
274
+ locateProduct.value = null
275
+ selectedLocation.value = null
276
+ locateSearchQuery.value = ''
277
+ locateSearchResults.value = []
278
+ }
279
+
280
+ async function handleAssign() {
281
+ if (!selectedLocation.value || !locateProduct.value) return
282
+
283
+ assigning.value = true
284
+ try {
285
+ await addProduct(selectedLocation.value.id, {
286
+ product_id: locateProduct.value.id,
287
+ quantity: 1,
288
+ })
289
+
290
+ // Remove from list
291
+ products.value = products.value.filter(p => p.id !== locateProduct.value!.id)
292
+ totalCount.value = Math.max(0, totalCount.value - 1)
293
+ closeLocateModal()
294
+ } catch {
295
+ // Error is in the composable
296
+ } finally {
297
+ assigning.value = false
298
+ }
299
+ }
300
+ </script>
301
+
302
+ <style scoped>
303
+ .unlocated-products {
304
+ max-width: 800px;
305
+ display: flex;
306
+ flex-direction: column;
307
+ gap: var(--space-4, 1rem);
308
+ }
309
+
310
+ .unlocated-products__header {
311
+ display: flex;
312
+ align-items: flex-start;
313
+ justify-content: space-between;
314
+ gap: var(--space-3, 0.75rem);
315
+ }
316
+
317
+ .unlocated-products__title {
318
+ margin: 0;
319
+ font-size: var(--text-xl, 1.25rem);
320
+ font-weight: 700;
321
+ color: var(--admin-text-primary);
322
+ }
323
+
324
+ .unlocated-products__subtitle {
325
+ margin: var(--space-1, 0.25rem) 0 0;
326
+ font-size: var(--text-sm, 0.875rem);
327
+ color: var(--admin-text-secondary);
328
+ }
329
+
330
+ .unlocated-products__product-name {
331
+ font-weight: 500;
332
+ color: var(--admin-text-primary);
333
+ }
334
+
335
+ .unlocated-products__sku {
336
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
337
+ font-size: var(--text-xs, 0.75rem);
338
+ color: var(--admin-text-secondary);
339
+ }
340
+
341
+ .unlocated-products__no-sku {
342
+ font-size: var(--text-xs, 0.75rem);
343
+ color: var(--admin-text-tertiary);
344
+ font-style: italic;
345
+ }
346
+
347
+ .unlocated-products__load-more {
348
+ display: flex;
349
+ align-items: center;
350
+ justify-content: space-between;
351
+ padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
352
+ border-top: 1px solid var(--admin-border);
353
+ }
354
+
355
+ .unlocated-products__showing {
356
+ margin: 0;
357
+ font-size: var(--text-sm, 0.875rem);
358
+ color: var(--admin-text-tertiary);
359
+ }
360
+
361
+ /* Locate modal */
362
+ .unlocated-products__locate-form {
363
+ display: flex;
364
+ flex-direction: column;
365
+ gap: var(--space-3, 0.75rem);
366
+ }
367
+
368
+ .unlocated-products__locate-search {
369
+ display: flex;
370
+ flex-direction: column;
371
+ gap: var(--space-1, 0.25rem);
372
+ }
373
+
374
+ .unlocated-products__locate-label {
375
+ font-size: var(--text-sm, 0.875rem);
376
+ font-weight: 500;
377
+ color: var(--admin-text-primary);
378
+ }
379
+
380
+ .unlocated-products__locate-input {
381
+ width: 100%;
382
+ height: 44px;
383
+ padding: 0 var(--space-3, 0.75rem);
384
+ border: 1px solid var(--admin-border);
385
+ border-radius: 6px;
386
+ background: var(--admin-card-bg);
387
+ color: var(--admin-text-primary);
388
+ font-size: var(--text-sm, 0.875rem);
389
+ outline: none;
390
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
391
+ }
392
+
393
+ .unlocated-products__locate-input:focus {
394
+ border-color: var(--color-primary, #2563eb);
395
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
396
+ }
397
+
398
+ .unlocated-products__locate-results {
399
+ margin: 0;
400
+ padding: 0;
401
+ list-style: none;
402
+ border: 1px solid var(--admin-border);
403
+ border-radius: 6px;
404
+ max-height: 200px;
405
+ overflow-y: auto;
406
+ background: var(--admin-card-bg);
407
+ }
408
+
409
+ .unlocated-products__locate-option {
410
+ display: flex;
411
+ align-items: center;
412
+ gap: var(--space-2, 0.5rem);
413
+ width: 100%;
414
+ padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
415
+ border: none;
416
+ background: none;
417
+ color: var(--admin-text-primary);
418
+ font-size: var(--text-sm, 0.875rem);
419
+ cursor: pointer;
420
+ text-align: left;
421
+ transition: background-color 0.1s ease;
422
+ }
423
+
424
+ .unlocated-products__locate-option:hover,
425
+ .unlocated-products__locate-option--selected {
426
+ background: var(--admin-content-bg);
427
+ }
428
+
429
+ .unlocated-products__locate-option:focus-visible {
430
+ outline: 2px solid var(--admin-focus-ring, #2563eb);
431
+ outline-offset: -2px;
432
+ }
433
+
434
+ .unlocated-products__locate-option-name {
435
+ flex: 1;
436
+ min-width: 0;
437
+ overflow: hidden;
438
+ text-overflow: ellipsis;
439
+ white-space: nowrap;
440
+ }
441
+
442
+ .unlocated-products__locate-no-results {
443
+ margin: 0;
444
+ font-size: var(--text-sm, 0.875rem);
445
+ color: var(--admin-text-tertiary);
446
+ font-style: italic;
447
+ text-align: center;
448
+ padding: var(--space-3, 0.75rem);
449
+ }
450
+
451
+ .unlocated-products__locate-selected {
452
+ display: flex;
453
+ align-items: center;
454
+ gap: var(--space-2, 0.5rem);
455
+ padding: var(--space-3, 0.75rem);
456
+ background: color-mix(in srgb, var(--color-success, #059669) 8%, var(--admin-card-bg));
457
+ border: 1px solid color-mix(in srgb, var(--color-success, #059669) 20%, var(--admin-border));
458
+ border-radius: 6px;
459
+ font-size: var(--text-sm, 0.875rem);
460
+ color: var(--admin-text-primary);
461
+ }
462
+
463
+ .unlocated-products__locate-selected-label {
464
+ font-weight: 500;
465
+ color: var(--color-success, #059669);
466
+ flex-shrink: 0;
467
+ }
468
+ </style>