@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,833 @@
1
+ <template>
2
+ <div class="count-entry">
3
+ <!-- Header bar -->
4
+ <div class="count-entry__header">
5
+ <button
6
+ type="button"
7
+ class="count-entry__back-btn"
8
+ aria-label="Go back"
9
+ @click="handleBack"
10
+ >
11
+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
12
+ <path d="M13 4L7 10L13 16" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
13
+ </svg>
14
+ <span>Back</span>
15
+ </button>
16
+ <span v-if="session" class="count-entry__session-name">{{ session.name }}</span>
17
+ <span v-if="session" class="count-entry__location-count">
18
+ {{ session.progress.counted }}/{{ session.progress.total }} locations
19
+ </span>
20
+ </div>
21
+
22
+ <!-- Online/offline indicator -->
23
+ <div
24
+ class="count-entry__connectivity"
25
+ :class="{ 'count-entry__connectivity--offline': !isOnline }"
26
+ role="status"
27
+ >
28
+ <span v-if="isOnline" class="count-entry__connectivity-dot count-entry__connectivity-dot--online" aria-hidden="true" />
29
+ <span v-else class="count-entry__connectivity-dot count-entry__connectivity-dot--offline" aria-hidden="true" />
30
+ <span>{{ isOnline ? 'Online' : 'Offline -- counts will sync when connected' }}</span>
31
+ </div>
32
+
33
+ <!-- Location selector (when no location) -->
34
+ <div v-if="!currentLocation" class="count-entry__no-location">
35
+ <InvCard title="Select Location">
36
+ <p class="count-entry__no-location-text">
37
+ Scan a QR code or search for a location to begin counting.
38
+ </p>
39
+ <div class="count-entry__no-location-actions">
40
+ <InvButton variant="primary" size="lg" @click="handleScanLocation">
41
+ <template #icon-left>
42
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
43
+ <rect x="2" y="2" width="5" height="5" rx="1" stroke="currentColor" stroke-width="1.5" />
44
+ <rect x="11" y="2" width="5" height="5" rx="1" stroke="currentColor" stroke-width="1.5" />
45
+ <rect x="2" y="11" width="5" height="5" rx="1" stroke="currentColor" stroke-width="1.5" />
46
+ <rect x="11" y="11" width="5" height="5" rx="1" stroke="currentColor" stroke-width="1.5" />
47
+ </svg>
48
+ </template>
49
+ Scan QR Code
50
+ </InvButton>
51
+ <div class="count-entry__search-row">
52
+ <input
53
+ v-model="locationSearchQuery"
54
+ type="search"
55
+ class="count-entry__search-input"
56
+ placeholder="Search location by code..."
57
+ @input="handleLocationSearch"
58
+ />
59
+ </div>
60
+ <ul
61
+ v-if="locationSearchResults.length > 0"
62
+ class="count-entry__search-results"
63
+ role="listbox"
64
+ aria-label="Location search results"
65
+ >
66
+ <li
67
+ v-for="loc in locationSearchResults"
68
+ :key="loc.id"
69
+ role="option"
70
+ :aria-selected="false"
71
+ >
72
+ <button
73
+ type="button"
74
+ class="count-entry__search-result-btn"
75
+ @click="selectLocation(loc)"
76
+ >
77
+ <LocationCodeBadge :code="loc.full_code" />
78
+ <span>{{ loc.name }}</span>
79
+ </button>
80
+ </li>
81
+ </ul>
82
+ </div>
83
+ </InvCard>
84
+ </div>
85
+
86
+ <!-- Counting screen -->
87
+ <template v-if="currentLocation">
88
+ <!-- Current location card -->
89
+ <div class="count-entry__location-card">
90
+ <LocationCodeBadge :code="currentLocation.full_code" />
91
+ <div class="count-entry__location-details">
92
+ <h2 class="count-entry__location-name">{{ currentLocation.name }}</h2>
93
+ <LocationBreadcrumb
94
+ v-if="currentLocation.path && currentLocation.path.length > 0"
95
+ :path="currentLocation.path"
96
+ @navigate="() => {}"
97
+ />
98
+ </div>
99
+ </div>
100
+
101
+ <!-- Products list -->
102
+ <div class="count-entry__products" role="list" aria-label="Products to count">
103
+ <p v-if="locationEntries.length === 0" class="count-entry__no-entries">
104
+ No entries for this location. Generate entries first.
105
+ </p>
106
+ <div
107
+ v-for="(entry, index) in locationEntries"
108
+ :key="entry.id"
109
+ role="listitem"
110
+ class="count-entry__product-row"
111
+ :class="{
112
+ 'count-entry__product-row--selected': selectedIndex === index,
113
+ 'count-entry__product-row--match': entry.counted_quantity !== null && entry.discrepancy === 0,
114
+ 'count-entry__product-row--discrepancy': entry.counted_quantity !== null && entry.discrepancy !== null && entry.discrepancy !== 0,
115
+ }"
116
+ @click="selectProduct(index)"
117
+ >
118
+ <div class="count-entry__product-info">
119
+ <span class="count-entry__product-name">{{ entry.product_name }}</span>
120
+ <span class="count-entry__product-expected">
121
+ Expected: {{ entry.expected_quantity ?? '-' }}
122
+ </span>
123
+ </div>
124
+ <div class="count-entry__product-count">
125
+ <label :for="`count-input-${entry.id}`" class="sr-only">
126
+ Counted quantity for {{ entry.product_name }}
127
+ </label>
128
+ <input
129
+ :id="`count-input-${entry.id}`"
130
+ :ref="(el) => { if (el) countInputRefs[index] = el as HTMLInputElement }"
131
+ type="text"
132
+ inputmode="decimal"
133
+ class="count-entry__count-input"
134
+ :class="{
135
+ 'count-entry__count-input--match': localCounts[index] !== '' && Number(localCounts[index]) === entry.expected_quantity,
136
+ 'count-entry__count-input--discrepancy': localCounts[index] !== '' && Number(localCounts[index]) !== entry.expected_quantity,
137
+ }"
138
+ :value="localCounts[index]"
139
+ readonly
140
+ @focus="selectProduct(index)"
141
+ />
142
+ <span
143
+ v-if="entry.counted_quantity !== null && entry.discrepancy === 0"
144
+ class="count-entry__status-icon count-entry__status-icon--match"
145
+ aria-label="Match"
146
+ >
147
+ &#x2713;
148
+ </span>
149
+ <span
150
+ v-else-if="entry.counted_quantity !== null && entry.discrepancy !== null && entry.discrepancy !== 0"
151
+ class="count-entry__status-icon count-entry__status-icon--discrepancy"
152
+ aria-label="Discrepancy"
153
+ >
154
+ &#x26A0;
155
+ </span>
156
+ </div>
157
+ </div>
158
+ </div>
159
+
160
+ <!-- Keypad -->
161
+ <div class="count-entry__keypad" role="group" aria-label="Numeric keypad">
162
+ <button
163
+ v-for="key in keypadKeys"
164
+ :key="key.label"
165
+ type="button"
166
+ class="count-entry__key"
167
+ :aria-label="key.ariaLabel || key.label"
168
+ :disabled="selectedIndex === null"
169
+ @click="handleKeyPress(key.value)"
170
+ >
171
+ {{ key.label }}
172
+ </button>
173
+ </div>
174
+
175
+ <!-- Bottom actions -->
176
+ <div class="count-entry__bottom-actions">
177
+ <InvButton
178
+ variant="primary"
179
+ size="lg"
180
+ :disabled="!allCounted"
181
+ :loading="completing"
182
+ class="count-entry__complete-btn"
183
+ @click="handleCompleteLocation"
184
+ >
185
+ Complete Location
186
+ </InvButton>
187
+ <InvButton
188
+ variant="secondary"
189
+ size="lg"
190
+ class="count-entry__scan-next-btn"
191
+ @click="handleScanLocation"
192
+ >
193
+ <template #icon-left>
194
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
195
+ <rect x="2" y="2" width="5" height="5" rx="1" stroke="currentColor" stroke-width="1.5" />
196
+ <rect x="11" y="2" width="5" height="5" rx="1" stroke="currentColor" stroke-width="1.5" />
197
+ <rect x="2" y="11" width="5" height="5" rx="1" stroke="currentColor" stroke-width="1.5" />
198
+ <rect x="11" y="11" width="5" height="5" rx="1" stroke="currentColor" stroke-width="1.5" />
199
+ </svg>
200
+ </template>
201
+ Scan Next Location
202
+ </InvButton>
203
+ </div>
204
+ </template>
205
+ </div>
206
+ </template>
207
+
208
+ <script setup lang="ts">
209
+ import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
210
+ import { useRouter } from 'vue-router'
211
+ import { useCountSessions } from '../../composables/useCountSessions'
212
+ import { useLocations } from '../../composables/useLocations'
213
+ import { useLocationSearch } from '../../composables/useLocationSearch'
214
+ import { useCountStore } from '../../stores/countStore'
215
+ import type { Location, CountEntry } from '../../types'
216
+ import InvCard from '../shared/InvCard.vue'
217
+ import InvButton from '../shared/InvButton.vue'
218
+ import LocationCodeBadge from '../locations/LocationCodeBadge.vue'
219
+ import LocationBreadcrumb from '../locations/LocationBreadcrumb.vue'
220
+
221
+ const props = defineProps<{
222
+ sessionId: number
223
+ locationId?: number | null
224
+ }>()
225
+
226
+ const router = useRouter()
227
+ const countStore = useCountStore()
228
+ const {
229
+ currentSession: session,
230
+ entries,
231
+ fetchSession,
232
+ fetchEntries,
233
+ submitCount,
234
+ } = useCountSessions()
235
+
236
+ const locationsApi = useLocations()
237
+ const locationSearch = useLocationSearch()
238
+
239
+ const currentLocation = ref<Location | null>(null)
240
+ const selectedIndex = ref<number | null>(null)
241
+ const localCounts = ref<string[]>([])
242
+ const countInputRefs: Record<number, HTMLInputElement> = {}
243
+ const completing = ref(false)
244
+ const isOnline = ref(navigator.onLine)
245
+ const locationSearchQuery = ref('')
246
+ const locationSearchResults = ref<Location[]>([])
247
+
248
+ interface KeypadKey {
249
+ label: string
250
+ value: string
251
+ ariaLabel?: string
252
+ wide?: boolean
253
+ }
254
+
255
+ const keypadKeys: KeypadKey[] = [
256
+ { label: '7', value: '7' },
257
+ { label: '8', value: '8' },
258
+ { label: '9', value: '9' },
259
+ { label: '4', value: '4' },
260
+ { label: '5', value: '5' },
261
+ { label: '6', value: '6' },
262
+ { label: '1', value: '1' },
263
+ { label: '2', value: '2' },
264
+ { label: '3', value: '3' },
265
+ { label: '.', value: '.', ariaLabel: 'Decimal point' },
266
+ { label: '0', value: '0' },
267
+ { label: '\u232B', value: 'backspace', ariaLabel: 'Backspace' },
268
+ ]
269
+
270
+ const locationEntries = computed<CountEntry[]>(() => {
271
+ if (!currentLocation.value) return []
272
+ return entries.value.filter(e => e.location_id === currentLocation.value!.id)
273
+ })
274
+
275
+ const allCounted = computed(() => {
276
+ if (locationEntries.value.length === 0) return false
277
+ return localCounts.value.every(c => c !== '')
278
+ })
279
+
280
+ onMounted(async () => {
281
+ countStore.loadQueue()
282
+ window.addEventListener('online', handleOnline)
283
+ window.addEventListener('offline', handleOffline)
284
+
285
+ await fetchSession(props.sessionId)
286
+ await fetchEntries(props.sessionId)
287
+
288
+ if (props.locationId) {
289
+ try {
290
+ const loc = await locationsApi.fetchLocation(props.locationId)
291
+ setLocation(loc)
292
+ } catch {
293
+ // Location not found, show picker
294
+ }
295
+ }
296
+ })
297
+
298
+ onBeforeUnmount(() => {
299
+ window.removeEventListener('online', handleOnline)
300
+ window.removeEventListener('offline', handleOffline)
301
+ })
302
+
303
+ watch(locationEntries, (newEntries) => {
304
+ localCounts.value = newEntries.map(e =>
305
+ e.counted_quantity !== null ? String(e.counted_quantity) : ''
306
+ )
307
+ selectedIndex.value = newEntries.findIndex(e => e.counted_quantity === null)
308
+ if (selectedIndex.value === -1 && newEntries.length > 0) {
309
+ selectedIndex.value = 0
310
+ }
311
+ })
312
+
313
+ function handleOnline() {
314
+ isOnline.value = true
315
+ syncPending()
316
+ }
317
+
318
+ function handleOffline() {
319
+ isOnline.value = false
320
+ }
321
+
322
+ async function syncPending() {
323
+ await countStore.syncPendingCounts(async (pending) => {
324
+ await submitCount(pending.sessionId, pending.entryId, pending.countedQuantity, pending.notes)
325
+ })
326
+ }
327
+
328
+ function setLocation(location: Location) {
329
+ currentLocation.value = location
330
+ countStore.setCurrentLocation(location.id)
331
+ }
332
+
333
+ function selectProduct(index: number) {
334
+ selectedIndex.value = index
335
+ nextTick(() => {
336
+ countInputRefs[index]?.focus()
337
+ })
338
+ }
339
+
340
+ function handleKeyPress(value: string) {
341
+ if (selectedIndex.value === null) return
342
+ const idx = selectedIndex.value
343
+ let current = localCounts.value[idx]
344
+
345
+ if (value === 'backspace') {
346
+ localCounts.value[idx] = current.slice(0, -1)
347
+ return
348
+ }
349
+
350
+ if (value === '.') {
351
+ if (current.includes('.')) return
352
+ localCounts.value[idx] = current + '.'
353
+ return
354
+ }
355
+
356
+ // First digit clears existing counted value if it came from server
357
+ const entry = locationEntries.value[idx]
358
+ if (entry.counted_quantity !== null && current === String(entry.counted_quantity)) {
359
+ localCounts.value[idx] = value
360
+ return
361
+ }
362
+
363
+ localCounts.value[idx] = current + value
364
+ }
365
+
366
+ async function submitCurrentCount() {
367
+ if (selectedIndex.value === null) return
368
+ const idx = selectedIndex.value
369
+ const entry = locationEntries.value[idx]
370
+ const countVal = localCounts.value[idx]
371
+ if (countVal === '') return
372
+
373
+ const quantity = parseFloat(countVal)
374
+ if (isNaN(quantity)) return
375
+
376
+ if (isOnline.value) {
377
+ try {
378
+ await submitCount(props.sessionId, entry.id, quantity)
379
+ } catch {
380
+ countStore.queueCount({
381
+ sessionId: props.sessionId,
382
+ entryId: entry.id,
383
+ countedQuantity: quantity,
384
+ })
385
+ }
386
+ } else {
387
+ countStore.queueCount({
388
+ sessionId: props.sessionId,
389
+ entryId: entry.id,
390
+ countedQuantity: quantity,
391
+ })
392
+ }
393
+
394
+ // Advance to next uncounted
395
+ const nextIdx = localCounts.value.findIndex((c, i) => i > idx && c === '')
396
+ if (nextIdx !== -1) {
397
+ selectProduct(nextIdx)
398
+ }
399
+ }
400
+
401
+ async function handleCompleteLocation() {
402
+ completing.value = true
403
+ try {
404
+ for (let i = 0; i < locationEntries.value.length; i++) {
405
+ const countVal = localCounts.value[i]
406
+ if (countVal === '') continue
407
+ const entry = locationEntries.value[i]
408
+ const quantity = parseFloat(countVal)
409
+ if (isNaN(quantity)) continue
410
+ if (entry.counted_quantity !== null && entry.counted_quantity === quantity) continue
411
+
412
+ if (isOnline.value) {
413
+ try {
414
+ await submitCount(props.sessionId, entry.id, quantity)
415
+ } catch {
416
+ countStore.queueCount({
417
+ sessionId: props.sessionId,
418
+ entryId: entry.id,
419
+ countedQuantity: quantity,
420
+ })
421
+ }
422
+ } else {
423
+ countStore.queueCount({
424
+ sessionId: props.sessionId,
425
+ entryId: entry.id,
426
+ countedQuantity: quantity,
427
+ })
428
+ }
429
+ }
430
+ await fetchEntries(props.sessionId)
431
+ await fetchSession(props.sessionId)
432
+ } finally {
433
+ completing.value = false
434
+ }
435
+ }
436
+
437
+ function handleScanLocation() {
438
+ router.push({ name: 'inventory-scan' })
439
+ }
440
+
441
+ function handleBack() {
442
+ router.push({ name: 'inventory-count-detail', params: { id: props.sessionId } })
443
+ }
444
+
445
+ function handleLocationSearch() {
446
+ locationSearch.search(locationSearchQuery.value)
447
+ locationSearchResults.value = locationSearch.results.value
448
+ }
449
+
450
+ async function selectLocation(location: Location) {
451
+ const full = await locationsApi.fetchLocation(location.id)
452
+ setLocation(full)
453
+ locationSearchQuery.value = ''
454
+ locationSearchResults.value = []
455
+ }
456
+ </script>
457
+
458
+ <style scoped>
459
+ .count-entry {
460
+ display: flex;
461
+ flex-direction: column;
462
+ min-height: 100vh;
463
+ background: var(--admin-content-bg);
464
+ }
465
+
466
+ .count-entry__header {
467
+ display: flex;
468
+ align-items: center;
469
+ gap: var(--space-3, 0.75rem);
470
+ padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
471
+ background: var(--admin-card-bg);
472
+ border-bottom: 1px solid var(--admin-border);
473
+ flex-shrink: 0;
474
+ }
475
+
476
+ .count-entry__back-btn {
477
+ display: flex;
478
+ align-items: center;
479
+ gap: var(--space-1, 0.25rem);
480
+ padding: var(--space-1, 0.25rem) var(--space-2, 0.5rem);
481
+ border: none;
482
+ border-radius: 6px;
483
+ background: none;
484
+ color: var(--color-primary, #2563eb);
485
+ font-size: var(--text-sm, 0.875rem);
486
+ font-weight: 500;
487
+ cursor: pointer;
488
+ flex-shrink: 0;
489
+ }
490
+
491
+ .count-entry__back-btn:hover {
492
+ background: var(--admin-content-bg);
493
+ }
494
+
495
+ .count-entry__back-btn:focus-visible {
496
+ outline: 2px solid var(--admin-focus-ring, #2563eb);
497
+ outline-offset: 2px;
498
+ }
499
+
500
+ .count-entry__session-name {
501
+ font-size: var(--text-sm, 0.875rem);
502
+ font-weight: 600;
503
+ color: var(--admin-text-primary);
504
+ overflow: hidden;
505
+ text-overflow: ellipsis;
506
+ white-space: nowrap;
507
+ min-width: 0;
508
+ flex: 1;
509
+ }
510
+
511
+ .count-entry__location-count {
512
+ font-size: var(--text-xs, 0.75rem);
513
+ color: var(--admin-text-tertiary);
514
+ font-variant-numeric: tabular-nums;
515
+ flex-shrink: 0;
516
+ }
517
+
518
+ .count-entry__connectivity {
519
+ display: flex;
520
+ align-items: center;
521
+ gap: var(--space-2, 0.5rem);
522
+ padding: var(--space-1, 0.25rem) var(--space-4, 1rem);
523
+ font-size: var(--text-xs, 0.75rem);
524
+ color: var(--color-success, #059669);
525
+ background: color-mix(in srgb, var(--color-success, #059669) 8%, var(--admin-card-bg));
526
+ flex-shrink: 0;
527
+ }
528
+
529
+ .count-entry__connectivity--offline {
530
+ color: var(--color-warning, #d97706);
531
+ background: color-mix(in srgb, var(--color-warning, #d97706) 8%, var(--admin-card-bg));
532
+ }
533
+
534
+ .count-entry__connectivity-dot {
535
+ width: 8px;
536
+ height: 8px;
537
+ border-radius: 50%;
538
+ flex-shrink: 0;
539
+ }
540
+
541
+ .count-entry__connectivity-dot--online {
542
+ background: var(--color-success, #059669);
543
+ }
544
+
545
+ .count-entry__connectivity-dot--offline {
546
+ background: var(--color-warning, #d97706);
547
+ }
548
+
549
+ .count-entry__no-location {
550
+ padding: var(--space-4, 1rem);
551
+ flex: 1;
552
+ }
553
+
554
+ .count-entry__no-location-text {
555
+ margin: 0 0 var(--space-4, 1rem);
556
+ font-size: var(--text-sm, 0.875rem);
557
+ color: var(--admin-text-secondary);
558
+ }
559
+
560
+ .count-entry__no-location-actions {
561
+ display: flex;
562
+ flex-direction: column;
563
+ gap: var(--space-3, 0.75rem);
564
+ }
565
+
566
+ .count-entry__search-row {
567
+ display: flex;
568
+ gap: var(--space-2, 0.5rem);
569
+ }
570
+
571
+ .count-entry__search-input {
572
+ flex: 1;
573
+ height: 44px;
574
+ padding: 0 var(--space-3, 0.75rem);
575
+ border: 1px solid var(--admin-border);
576
+ border-radius: 6px;
577
+ background: var(--admin-card-bg);
578
+ color: var(--admin-text-primary);
579
+ font-size: var(--text-sm, 0.875rem);
580
+ outline: none;
581
+ }
582
+
583
+ .count-entry__search-input:focus {
584
+ border-color: var(--color-primary, #2563eb);
585
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
586
+ }
587
+
588
+ .count-entry__search-results {
589
+ margin: 0;
590
+ padding: 0;
591
+ list-style: none;
592
+ border: 1px solid var(--admin-border);
593
+ border-radius: 6px;
594
+ max-height: 200px;
595
+ overflow-y: auto;
596
+ background: var(--admin-card-bg);
597
+ }
598
+
599
+ .count-entry__search-result-btn {
600
+ display: flex;
601
+ align-items: center;
602
+ gap: var(--space-2, 0.5rem);
603
+ width: 100%;
604
+ padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
605
+ border: none;
606
+ background: none;
607
+ color: var(--admin-text-primary);
608
+ font-size: var(--text-sm, 0.875rem);
609
+ cursor: pointer;
610
+ text-align: left;
611
+ }
612
+
613
+ .count-entry__search-result-btn:hover {
614
+ background: var(--admin-content-bg);
615
+ }
616
+
617
+ .count-entry__search-result-btn:focus-visible {
618
+ outline: 2px solid var(--admin-focus-ring, #2563eb);
619
+ outline-offset: -2px;
620
+ }
621
+
622
+ .count-entry__location-card {
623
+ display: flex;
624
+ align-items: center;
625
+ gap: var(--space-3, 0.75rem);
626
+ padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
627
+ background: var(--admin-card-bg);
628
+ border-bottom: 1px solid var(--admin-border);
629
+ flex-shrink: 0;
630
+ }
631
+
632
+ .count-entry__location-details {
633
+ min-width: 0;
634
+ }
635
+
636
+ .count-entry__location-name {
637
+ margin: 0;
638
+ font-size: var(--text-base, 1rem);
639
+ font-weight: 600;
640
+ color: var(--admin-text-primary);
641
+ }
642
+
643
+ .count-entry__products {
644
+ flex: 1;
645
+ overflow-y: auto;
646
+ padding: var(--space-2, 0.5rem) 0;
647
+ }
648
+
649
+ .count-entry__no-entries {
650
+ padding: var(--space-6, 1.5rem) var(--space-4, 1rem);
651
+ text-align: center;
652
+ color: var(--admin-text-tertiary);
653
+ font-style: italic;
654
+ font-size: var(--text-sm, 0.875rem);
655
+ margin: 0;
656
+ }
657
+
658
+ .count-entry__product-row {
659
+ display: flex;
660
+ align-items: center;
661
+ justify-content: space-between;
662
+ gap: var(--space-3, 0.75rem);
663
+ padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
664
+ background: var(--admin-card-bg);
665
+ border-bottom: 1px solid var(--admin-border);
666
+ cursor: pointer;
667
+ transition: background-color 0.1s ease;
668
+ }
669
+
670
+ .count-entry__product-row:hover {
671
+ background: var(--admin-content-bg);
672
+ }
673
+
674
+ .count-entry__product-row--selected {
675
+ background: color-mix(in srgb, var(--color-primary, #2563eb) 8%, var(--admin-card-bg));
676
+ border-left: 3px solid var(--color-primary, #2563eb);
677
+ }
678
+
679
+ .count-entry__product-row--match {
680
+ border-left: 3px solid var(--color-success, #059669);
681
+ }
682
+
683
+ .count-entry__product-row--discrepancy {
684
+ border-left: 3px solid var(--color-warning, #d97706);
685
+ }
686
+
687
+ .count-entry__product-info {
688
+ display: flex;
689
+ flex-direction: column;
690
+ gap: var(--space-1, 0.25rem);
691
+ min-width: 0;
692
+ }
693
+
694
+ .count-entry__product-name {
695
+ font-size: var(--text-sm, 0.875rem);
696
+ font-weight: 500;
697
+ color: var(--admin-text-primary);
698
+ overflow: hidden;
699
+ text-overflow: ellipsis;
700
+ white-space: nowrap;
701
+ }
702
+
703
+ .count-entry__product-expected {
704
+ font-size: var(--text-xs, 0.75rem);
705
+ color: var(--admin-text-tertiary);
706
+ font-variant-numeric: tabular-nums;
707
+ }
708
+
709
+ .count-entry__product-count {
710
+ display: flex;
711
+ align-items: center;
712
+ gap: var(--space-2, 0.5rem);
713
+ flex-shrink: 0;
714
+ }
715
+
716
+ .count-entry__count-input {
717
+ width: 80px;
718
+ height: 44px;
719
+ padding: 0 var(--space-2, 0.5rem);
720
+ border: 2px solid var(--admin-border);
721
+ border-radius: 6px;
722
+ background: var(--admin-card-bg);
723
+ color: var(--admin-text-primary);
724
+ font-size: var(--text-lg, 1.125rem);
725
+ font-weight: 600;
726
+ font-variant-numeric: tabular-nums;
727
+ text-align: center;
728
+ outline: none;
729
+ }
730
+
731
+ .count-entry__count-input:focus {
732
+ border-color: var(--color-primary, #2563eb);
733
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
734
+ }
735
+
736
+ .count-entry__count-input--match {
737
+ border-color: var(--color-success, #059669);
738
+ color: var(--color-success, #059669);
739
+ }
740
+
741
+ .count-entry__count-input--discrepancy {
742
+ border-color: var(--color-warning, #d97706);
743
+ color: var(--color-warning, #d97706);
744
+ }
745
+
746
+ .count-entry__status-icon {
747
+ font-size: var(--text-lg, 1.125rem);
748
+ }
749
+
750
+ .count-entry__status-icon--match {
751
+ color: var(--color-success, #059669);
752
+ }
753
+
754
+ .count-entry__status-icon--discrepancy {
755
+ color: var(--color-warning, #d97706);
756
+ }
757
+
758
+ .count-entry__keypad {
759
+ display: grid;
760
+ grid-template-columns: repeat(3, 1fr);
761
+ gap: var(--space-2, 0.5rem);
762
+ padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
763
+ background: var(--admin-card-bg);
764
+ border-top: 1px solid var(--admin-border);
765
+ flex-shrink: 0;
766
+ }
767
+
768
+ .count-entry__key {
769
+ min-height: 60px;
770
+ min-width: 60px;
771
+ display: flex;
772
+ align-items: center;
773
+ justify-content: center;
774
+ border: 1px solid var(--admin-border);
775
+ border-radius: 8px;
776
+ background: var(--admin-content-bg);
777
+ color: var(--admin-text-primary);
778
+ font-size: var(--text-xl, 1.25rem);
779
+ font-weight: 600;
780
+ cursor: pointer;
781
+ user-select: none;
782
+ transition: background-color 0.1s ease;
783
+ -webkit-tap-highlight-color: transparent;
784
+ }
785
+
786
+ .count-entry__key:hover:not(:disabled) {
787
+ background: var(--admin-table-header-bg);
788
+ }
789
+
790
+ .count-entry__key:active:not(:disabled) {
791
+ background: var(--admin-border);
792
+ transform: scale(0.97);
793
+ }
794
+
795
+ .count-entry__key:disabled {
796
+ opacity: 0.3;
797
+ cursor: not-allowed;
798
+ }
799
+
800
+ .count-entry__key:focus-visible {
801
+ outline: 2px solid var(--admin-focus-ring, #2563eb);
802
+ outline-offset: 2px;
803
+ }
804
+
805
+ .count-entry__bottom-actions {
806
+ display: flex;
807
+ gap: var(--space-2, 0.5rem);
808
+ padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
809
+ background: var(--admin-card-bg);
810
+ border-top: 1px solid var(--admin-border);
811
+ flex-shrink: 0;
812
+ }
813
+
814
+ .count-entry__complete-btn {
815
+ flex: 1;
816
+ }
817
+
818
+ .count-entry__scan-next-btn {
819
+ flex-shrink: 0;
820
+ }
821
+
822
+ .sr-only {
823
+ position: absolute;
824
+ width: 1px;
825
+ height: 1px;
826
+ padding: 0;
827
+ margin: -1px;
828
+ overflow: hidden;
829
+ clip: rect(0, 0, 0, 0);
830
+ white-space: nowrap;
831
+ border-width: 0;
832
+ }
833
+ </style>