@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,528 @@
1
+ <template>
2
+ <div class="label-batch">
3
+ <div class="label-batch__header">
4
+ <h1 class="label-batch__title">Print Labels</h1>
5
+ </div>
6
+
7
+ <!-- Format selector -->
8
+ <div class="label-batch__format">
9
+ <InvSelect
10
+ v-model="selectedFormat"
11
+ :options="formatOptions"
12
+ label="Format"
13
+ />
14
+ </div>
15
+
16
+ <!-- Quick selectors -->
17
+ <InvCard title="Select locations to print" class="label-batch__selectors">
18
+ <div class="label-batch__quick-selectors">
19
+ <button
20
+ v-for="selector in quickSelectors"
21
+ :key="selector.label"
22
+ type="button"
23
+ class="label-batch__quick-btn"
24
+ @click="selector.action"
25
+ >
26
+ {{ selector.label }}
27
+ </button>
28
+ </div>
29
+
30
+ <!-- By building/type dropdowns -->
31
+ <div class="label-batch__filter-row">
32
+ <InvSelect
33
+ v-model="filterBuilding"
34
+ :options="buildingOptions"
35
+ placeholder="By Building"
36
+ />
37
+ <InvSelect
38
+ v-model="filterType"
39
+ :options="typeFilterOptions"
40
+ placeholder="By Type"
41
+ />
42
+ </div>
43
+ </InvCard>
44
+
45
+ <!-- Loading -->
46
+ <InvCard v-if="loadingLocations" loading />
47
+
48
+ <!-- Location list with checkboxes -->
49
+ <InvCard
50
+ v-else-if="groupedLocations.length > 0"
51
+ class="label-batch__locations"
52
+ no-padding
53
+ >
54
+ <div
55
+ v-for="group in groupedLocations"
56
+ :key="group.parentName"
57
+ class="label-batch__group"
58
+ >
59
+ <button
60
+ type="button"
61
+ class="label-batch__group-header"
62
+ @click="toggleGroup(group.parentName)"
63
+ >
64
+ <input
65
+ type="checkbox"
66
+ :checked="isGroupFullySelected(group)"
67
+ :indeterminate="isGroupPartiallySelected(group)"
68
+ class="label-batch__checkbox"
69
+ :aria-label="`Select all in ${group.parentName}`"
70
+ @click.stop="toggleGroupSelection(group)"
71
+ />
72
+ <span class="label-batch__group-name">{{ group.parentName }}</span>
73
+ <span class="label-batch__group-count">{{ group.locations.length }}</span>
74
+ <svg
75
+ class="label-batch__group-chevron"
76
+ :class="{ 'label-batch__group-chevron--collapsed': !expandedGroups.has(group.parentName) }"
77
+ width="16"
78
+ height="16"
79
+ viewBox="0 0 16 16"
80
+ fill="none"
81
+ aria-hidden="true"
82
+ >
83
+ <path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
84
+ </svg>
85
+ </button>
86
+
87
+ <div
88
+ v-show="expandedGroups.has(group.parentName)"
89
+ class="label-batch__group-items"
90
+ >
91
+ <label
92
+ v-for="loc in group.locations"
93
+ :key="loc.id"
94
+ class="label-batch__location-row"
95
+ >
96
+ <input
97
+ type="checkbox"
98
+ :checked="selectedIds.has(loc.id)"
99
+ class="label-batch__checkbox"
100
+ @change="toggleLocation(loc.id)"
101
+ />
102
+ <LocationCodeBadge :code="loc.full_code" />
103
+ <span class="label-batch__location-name">{{ loc.name }}</span>
104
+ <InvBadge variant="muted" size="sm">{{ loc.type_label }}</InvBadge>
105
+ </label>
106
+ </div>
107
+ </div>
108
+ </InvCard>
109
+
110
+ <!-- Empty -->
111
+ <InvEmptyState
112
+ v-else-if="!loadingLocations"
113
+ title="No locations found"
114
+ description="Create locations first to generate labels."
115
+ />
116
+
117
+ <!-- Footer / print controls -->
118
+ <div v-if="selectedIds.size > 0" class="label-batch__footer">
119
+ <span class="label-batch__selected-count">
120
+ Selected: {{ selectedIds.size }} label{{ selectedIds.size !== 1 ? 's' : '' }}
121
+ </span>
122
+ <div class="label-batch__footer-actions">
123
+ <InvButton
124
+ variant="secondary"
125
+ size="md"
126
+ @click="handlePreview"
127
+ >
128
+ Preview All
129
+ </InvButton>
130
+ <InvButton
131
+ variant="primary"
132
+ size="md"
133
+ :loading="printing"
134
+ @click="handlePrint"
135
+ >
136
+ <template #icon-left>
137
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
138
+ <rect x="4" y="1" width="8" height="5" rx="1" stroke="currentColor" stroke-width="1.5" />
139
+ <rect x="2" y="6" width="12" height="6" rx="1" stroke="currentColor" stroke-width="1.5" />
140
+ <rect x="5" y="10" width="6" height="5" rx="1" stroke="currentColor" stroke-width="1.5" />
141
+ </svg>
142
+ </template>
143
+ Print {{ selectedIds.size }} Labels
144
+ </InvButton>
145
+ </div>
146
+ </div>
147
+ </div>
148
+ </template>
149
+
150
+ <script setup lang="ts">
151
+ import { ref, computed, onMounted, reactive } from 'vue'
152
+ import { useLocations } from '../../composables/useLocations'
153
+ import { useLabelPrinting } from '../../composables/useLabelPrinting'
154
+ import type { Location, LabelFormat, LocationType } from '../../types'
155
+ import InvCard from '../shared/InvCard.vue'
156
+ import InvButton from '../shared/InvButton.vue'
157
+ import InvSelect from '../shared/InvSelect.vue'
158
+ import InvBadge from '../shared/InvBadge.vue'
159
+ import InvEmptyState from '../shared/InvEmptyState.vue'
160
+ import LocationCodeBadge from '../locations/LocationCodeBadge.vue'
161
+
162
+ const { fetchRoots, locations } = useLocations()
163
+ const { printBatch } = useLabelPrinting()
164
+
165
+ const selectedFormat = ref<string>('standard')
166
+ const selectedIds = reactive(new Set<number>())
167
+ const expandedGroups = reactive(new Set<string>())
168
+ const loadingLocations = ref(true)
169
+ const printing = ref(false)
170
+ const filterBuilding = ref<string>('')
171
+ const filterType = ref<string>('')
172
+ const allLeafLocations = ref<Location[]>([])
173
+
174
+ const formatOptions = [
175
+ { value: 'standard', label: 'Standard' },
176
+ { value: 'small', label: 'Small' },
177
+ { value: 'pallet', label: 'Pallet' },
178
+ ]
179
+
180
+ const typeFilterOptions = computed(() => {
181
+ const types = new Set(allLeafLocations.value.map(l => l.type))
182
+ const options = [{ value: '', label: 'All Types' }]
183
+ types.forEach(t => {
184
+ options.push({ value: t, label: t.charAt(0).toUpperCase() + t.slice(1) })
185
+ })
186
+ return options
187
+ })
188
+
189
+ const buildingOptions = computed(() => {
190
+ const buildings = new Set<string>()
191
+ allLeafLocations.value.forEach(l => {
192
+ if (l.path && l.path.length > 0) {
193
+ buildings.add(l.path[0].name)
194
+ }
195
+ })
196
+ const options = [{ value: '', label: 'All Buildings' }]
197
+ buildings.forEach(b => {
198
+ options.push({ value: b, label: b })
199
+ })
200
+ return options
201
+ })
202
+
203
+ interface LocationGroup {
204
+ parentName: string
205
+ locations: Location[]
206
+ }
207
+
208
+ const filteredLocations = computed(() => {
209
+ let locs = allLeafLocations.value
210
+ if (filterType.value) {
211
+ locs = locs.filter(l => l.type === filterType.value)
212
+ }
213
+ if (filterBuilding.value) {
214
+ locs = locs.filter(l =>
215
+ l.path && l.path.length > 0 && l.path[0].name === filterBuilding.value
216
+ )
217
+ }
218
+ return locs
219
+ })
220
+
221
+ const groupedLocations = computed<LocationGroup[]>(() => {
222
+ const groups = new Map<string, Location[]>()
223
+
224
+ for (const loc of filteredLocations.value) {
225
+ const parentName = loc.path && loc.path.length >= 2
226
+ ? loc.path[loc.path.length - 2].name
227
+ : loc.path && loc.path.length === 1
228
+ ? loc.path[0].name
229
+ : 'Ungrouped'
230
+ if (!groups.has(parentName)) {
231
+ groups.set(parentName, [])
232
+ }
233
+ groups.get(parentName)!.push(loc)
234
+ }
235
+
236
+ return Array.from(groups.entries())
237
+ .map(([parentName, locs]) => ({ parentName, locations: locs }))
238
+ .sort((a, b) => a.parentName.localeCompare(b.parentName))
239
+ })
240
+
241
+ const quickSelectors = computed(() => [
242
+ { label: 'All Shelves', action: () => selectByType('shelf') },
243
+ { label: 'All Racks', action: () => selectByType('rack') },
244
+ { label: 'All Bins', action: () => selectByType('bin') },
245
+ { label: 'Select All', action: selectAll },
246
+ { label: 'Deselect All', action: deselectAll },
247
+ ])
248
+
249
+ onMounted(async () => {
250
+ loadingLocations.value = true
251
+ try {
252
+ await fetchRoots()
253
+ allLeafLocations.value = flattenLeafLocations(locations.value)
254
+ // Expand all groups by default
255
+ groupedLocations.value.forEach(g => expandedGroups.add(g.parentName))
256
+ } finally {
257
+ loadingLocations.value = false
258
+ }
259
+ })
260
+
261
+ function flattenLeafLocations(locs: Location[]): Location[] {
262
+ const result: Location[] = []
263
+ function walk(nodes: Location[], path: Location['path']) {
264
+ for (const node of nodes) {
265
+ const currentPath = [...(path || []), {
266
+ id: node.id,
267
+ name: node.name,
268
+ code: node.code,
269
+ full_code: node.full_code,
270
+ type: node.type,
271
+ }]
272
+ if (node.children && node.children.length > 0) {
273
+ walk(node.children, currentPath)
274
+ } else {
275
+ result.push({ ...node, path: currentPath })
276
+ }
277
+ }
278
+ }
279
+ walk(locs, [])
280
+ return result
281
+ }
282
+
283
+ function toggleGroup(name: string) {
284
+ if (expandedGroups.has(name)) {
285
+ expandedGroups.delete(name)
286
+ } else {
287
+ expandedGroups.add(name)
288
+ }
289
+ }
290
+
291
+ function toggleLocation(id: number) {
292
+ if (selectedIds.has(id)) {
293
+ selectedIds.delete(id)
294
+ } else {
295
+ selectedIds.add(id)
296
+ }
297
+ }
298
+
299
+ function isGroupFullySelected(group: LocationGroup): boolean {
300
+ return group.locations.every(l => selectedIds.has(l.id))
301
+ }
302
+
303
+ function isGroupPartiallySelected(group: LocationGroup): boolean {
304
+ const count = group.locations.filter(l => selectedIds.has(l.id)).length
305
+ return count > 0 && count < group.locations.length
306
+ }
307
+
308
+ function toggleGroupSelection(group: LocationGroup) {
309
+ if (isGroupFullySelected(group)) {
310
+ group.locations.forEach(l => selectedIds.delete(l.id))
311
+ } else {
312
+ group.locations.forEach(l => selectedIds.add(l.id))
313
+ }
314
+ }
315
+
316
+ function selectByType(type: LocationType) {
317
+ filteredLocations.value
318
+ .filter(l => l.type === type)
319
+ .forEach(l => selectedIds.add(l.id))
320
+ }
321
+
322
+ function selectAll() {
323
+ filteredLocations.value.forEach(l => selectedIds.add(l.id))
324
+ }
325
+
326
+ function deselectAll() {
327
+ selectedIds.clear()
328
+ }
329
+
330
+ async function handlePrint() {
331
+ if (selectedIds.size === 0) return
332
+ printing.value = true
333
+ try {
334
+ await printBatch(Array.from(selectedIds), selectedFormat.value as LabelFormat)
335
+ } finally {
336
+ printing.value = false
337
+ }
338
+ }
339
+
340
+ function handlePreview() {
341
+ // Preview is the same as print but without recording
342
+ handlePrint()
343
+ }
344
+ </script>
345
+
346
+ <style scoped>
347
+ .label-batch {
348
+ max-width: 800px;
349
+ display: flex;
350
+ flex-direction: column;
351
+ gap: var(--space-5, 1.25rem);
352
+ }
353
+
354
+ .label-batch__header {
355
+ display: flex;
356
+ align-items: center;
357
+ justify-content: space-between;
358
+ }
359
+
360
+ .label-batch__title {
361
+ margin: 0;
362
+ font-size: var(--text-xl, 1.25rem);
363
+ font-weight: 700;
364
+ color: var(--admin-text-primary);
365
+ }
366
+
367
+ .label-batch__format {
368
+ max-width: 200px;
369
+ }
370
+
371
+ .label-batch__quick-selectors {
372
+ display: flex;
373
+ flex-wrap: wrap;
374
+ gap: var(--space-2, 0.5rem);
375
+ margin-bottom: var(--space-4, 1rem);
376
+ }
377
+
378
+ .label-batch__quick-btn {
379
+ padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
380
+ border: 1px solid var(--admin-border);
381
+ border-radius: 6px;
382
+ background: var(--admin-card-bg);
383
+ color: var(--admin-text-primary);
384
+ font-size: var(--text-sm, 0.875rem);
385
+ cursor: pointer;
386
+ transition: background-color 0.15s ease, border-color 0.15s ease;
387
+ }
388
+
389
+ .label-batch__quick-btn:hover {
390
+ background: var(--admin-content-bg);
391
+ border-color: var(--admin-text-tertiary);
392
+ }
393
+
394
+ .label-batch__quick-btn:focus-visible {
395
+ outline: 2px solid var(--admin-focus-ring, #2563eb);
396
+ outline-offset: 2px;
397
+ }
398
+
399
+ .label-batch__filter-row {
400
+ display: flex;
401
+ gap: var(--space-3, 0.75rem);
402
+ }
403
+
404
+ .label-batch__filter-row > * {
405
+ flex: 1;
406
+ }
407
+
408
+ .label-batch__group {
409
+ border-bottom: 1px solid var(--admin-border);
410
+ }
411
+
412
+ .label-batch__group:last-child {
413
+ border-bottom: none;
414
+ }
415
+
416
+ .label-batch__group-header {
417
+ display: flex;
418
+ align-items: center;
419
+ gap: var(--space-3, 0.75rem);
420
+ width: 100%;
421
+ padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
422
+ border: none;
423
+ background: var(--admin-table-header-bg);
424
+ color: var(--admin-text-primary);
425
+ font-size: var(--text-sm, 0.875rem);
426
+ font-weight: 600;
427
+ cursor: pointer;
428
+ text-align: left;
429
+ }
430
+
431
+ .label-batch__group-header:hover {
432
+ background: var(--admin-content-bg);
433
+ }
434
+
435
+ .label-batch__group-header:focus-visible {
436
+ outline: 2px solid var(--admin-focus-ring, #2563eb);
437
+ outline-offset: -2px;
438
+ }
439
+
440
+ .label-batch__group-name {
441
+ flex: 1;
442
+ min-width: 0;
443
+ overflow: hidden;
444
+ text-overflow: ellipsis;
445
+ white-space: nowrap;
446
+ }
447
+
448
+ .label-batch__group-count {
449
+ font-size: var(--text-xs, 0.75rem);
450
+ color: var(--admin-text-tertiary);
451
+ font-weight: 400;
452
+ }
453
+
454
+ .label-batch__group-chevron {
455
+ color: var(--admin-text-tertiary);
456
+ transition: transform 0.2s ease;
457
+ flex-shrink: 0;
458
+ }
459
+
460
+ .label-batch__group-chevron--collapsed {
461
+ transform: rotate(-90deg);
462
+ }
463
+
464
+ .label-batch__group-items {
465
+ padding: 0;
466
+ }
467
+
468
+ .label-batch__location-row {
469
+ display: flex;
470
+ align-items: center;
471
+ gap: var(--space-3, 0.75rem);
472
+ padding: var(--space-2, 0.5rem) var(--space-4, 1rem) var(--space-2, 0.5rem) var(--space-8, 2rem);
473
+ cursor: pointer;
474
+ transition: background-color 0.1s ease;
475
+ border-bottom: 1px solid var(--admin-border);
476
+ font-size: var(--text-sm, 0.875rem);
477
+ }
478
+
479
+ .label-batch__location-row:last-child {
480
+ border-bottom: none;
481
+ }
482
+
483
+ .label-batch__location-row:hover {
484
+ background: var(--admin-content-bg);
485
+ }
486
+
487
+ .label-batch__checkbox {
488
+ width: 18px;
489
+ height: 18px;
490
+ accent-color: var(--color-primary, #2563eb);
491
+ cursor: pointer;
492
+ flex-shrink: 0;
493
+ }
494
+
495
+ .label-batch__location-name {
496
+ flex: 1;
497
+ min-width: 0;
498
+ overflow: hidden;
499
+ text-overflow: ellipsis;
500
+ white-space: nowrap;
501
+ color: var(--admin-text-primary);
502
+ }
503
+
504
+ .label-batch__footer {
505
+ position: sticky;
506
+ bottom: 0;
507
+ display: flex;
508
+ align-items: center;
509
+ justify-content: space-between;
510
+ gap: var(--space-3, 0.75rem);
511
+ padding: var(--space-4, 1rem);
512
+ background: var(--admin-card-bg);
513
+ border: 1px solid var(--admin-border);
514
+ border-radius: 8px;
515
+ box-shadow: var(--shadow-md);
516
+ }
517
+
518
+ .label-batch__selected-count {
519
+ font-size: var(--text-sm, 0.875rem);
520
+ font-weight: 600;
521
+ color: var(--admin-text-primary);
522
+ }
523
+
524
+ .label-batch__footer-actions {
525
+ display: flex;
526
+ gap: var(--space-2, 0.5rem);
527
+ }
528
+ </style>