@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,884 @@
1
+ <template>
2
+ <div class="location-detail">
3
+ <!-- Loading state -->
4
+ <div v-if="loading" class="location-detail__loading" aria-live="polite">
5
+ <div class="location-detail__loading-header">
6
+ <div class="location-detail__skeleton location-detail__skeleton--breadcrumb" />
7
+ <div class="location-detail__skeleton location-detail__skeleton--title" />
8
+ <div class="location-detail__skeleton location-detail__skeleton--badges" />
9
+ </div>
10
+ <div class="location-detail__skeleton location-detail__skeleton--card" />
11
+ <div class="location-detail__skeleton location-detail__skeleton--card" />
12
+ <span class="sr-only">Loading location details...</span>
13
+ </div>
14
+
15
+ <!-- Error state -->
16
+ <InvEmptyState
17
+ v-else-if="error"
18
+ title="Failed to load location"
19
+ :description="error"
20
+ action-label="Retry"
21
+ @action="loadLocation"
22
+ />
23
+
24
+ <!-- Detail content -->
25
+ <template v-else-if="location">
26
+ <!-- 1. Header bar -->
27
+ <header class="location-detail__header">
28
+ <LocationBreadcrumb
29
+ v-if="location.path && location.path.length > 0"
30
+ :path="location.path"
31
+ @navigate="handleBreadcrumbNavigate"
32
+ />
33
+
34
+ <div class="location-detail__title-row">
35
+ <div class="location-detail__title-area">
36
+ <LocationTypeIcon :type="location.type" size="lg" />
37
+ <h1 class="location-detail__name">{{ location.name }}</h1>
38
+ <LocationCodeBadge :code="location.full_code" />
39
+ <InvBadge variant="muted" size="sm">{{ location.type_label }}</InvBadge>
40
+ <InvBadge
41
+ :variant="location.is_active ? 'success' : 'error'"
42
+ size="sm"
43
+ >
44
+ {{ location.is_active ? 'Active' : 'Inactive' }}
45
+ </InvBadge>
46
+ </div>
47
+
48
+ <div class="location-detail__actions">
49
+ <InvButton variant="secondary" size="sm" @click="showEditForm = true">
50
+ <template #icon-left>
51
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
52
+ <path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
53
+ <path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
54
+ </svg>
55
+ </template>
56
+ Edit
57
+ </InvButton>
58
+ <InvButton variant="secondary" size="sm" @click="handlePrintLabel">
59
+ <template #icon-left>
60
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
61
+ <path d="M6 9V2h12v7M6 18H4a2 2 0 01-2-2v-5a2 2 0 012-2h16a2 2 0 012 2v5a2 2 0 01-2 2h-2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
62
+ <rect x="6" y="14" width="12" height="8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
63
+ </svg>
64
+ </template>
65
+ Print Label
66
+ </InvButton>
67
+ <InvButton variant="danger" size="sm" @click="handleDelete">
68
+ <template #icon-left>
69
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
70
+ <path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
71
+ </svg>
72
+ </template>
73
+ Delete
74
+ </InvButton>
75
+ </div>
76
+ </div>
77
+ </header>
78
+
79
+ <!-- 2. Info card -->
80
+ <InvCard
81
+ v-if="location.description || location.metadata"
82
+ title="Details"
83
+ compact
84
+ >
85
+ <div class="location-detail__info-grid">
86
+ <div v-if="location.description" class="location-detail__info-item">
87
+ <span class="location-detail__info-label">Description</span>
88
+ <p class="location-detail__info-value">{{ location.description }}</p>
89
+ </div>
90
+ <div class="location-detail__info-item">
91
+ <span class="location-detail__info-label">Created</span>
92
+ <span class="location-detail__info-value">{{ formatDate(location.created_at) }}</span>
93
+ </div>
94
+ <div class="location-detail__info-item">
95
+ <span class="location-detail__info-label">Last Updated</span>
96
+ <span class="location-detail__info-value">{{ formatDate(location.updated_at) }}</span>
97
+ </div>
98
+ <div v-if="location.is_mobile" class="location-detail__info-item">
99
+ <span class="location-detail__info-label">Mobile</span>
100
+ <InvBadge variant="info" size="sm">Mobile Location</InvBadge>
101
+ </div>
102
+ </div>
103
+ </InvCard>
104
+
105
+ <!-- 3. Children card -->
106
+ <InvCard
107
+ v-if="children.length > 0 || location.children_count"
108
+ title="Child Locations"
109
+ compact
110
+ no-padding
111
+ >
112
+ <template #actions>
113
+ <InvButton variant="ghost" size="sm" @click="showCreateChildForm = true">
114
+ + Add Child
115
+ </InvButton>
116
+ </template>
117
+ <InvTable
118
+ :columns="childColumns"
119
+ :data="children"
120
+ :loading="childrenLoading"
121
+ clickable
122
+ empty-message="No child locations."
123
+ @row-click="handleChildClick"
124
+ >
125
+ <template #cell-name="{ row }">
126
+ <div class="location-detail__child-cell">
127
+ <LocationTypeIcon :type="row.type" size="sm" />
128
+ <span>{{ row.name }}</span>
129
+ </div>
130
+ </template>
131
+ <template #cell-type_label="{ value }">
132
+ <InvBadge variant="muted" size="sm">{{ value }}</InvBadge>
133
+ </template>
134
+ <template #cell-full_code="{ value }">
135
+ <LocationCodeBadge :code="value" />
136
+ </template>
137
+ <template #cell-product_count="{ value }">
138
+ <span class="location-detail__count">{{ value ?? 0 }}</span>
139
+ </template>
140
+ </InvTable>
141
+ </InvCard>
142
+
143
+ <!-- 4. Products card -->
144
+ <InvCard
145
+ title="Products"
146
+ compact
147
+ no-padding
148
+ >
149
+ <template #actions>
150
+ <span class="location-detail__product-count-label">
151
+ {{ products.length }} {{ products.length === 1 ? 'product' : 'products' }}
152
+ </span>
153
+ </template>
154
+ <div v-if="productsLoading" class="location-detail__loading-inline">
155
+ <div class="location-detail__skeleton location-detail__skeleton--row" />
156
+ <div class="location-detail__skeleton location-detail__skeleton--row" />
157
+ <div class="location-detail__skeleton location-detail__skeleton--row" />
158
+ </div>
159
+ <div v-else-if="products.length === 0" class="location-detail__empty-inline">
160
+ <p>No products at this location.</p>
161
+ </div>
162
+ <InvTable
163
+ v-else
164
+ :columns="productColumns"
165
+ :data="products"
166
+ empty-message="No products at this location."
167
+ >
168
+ <template #cell-product_name="{ row }">
169
+ <div class="location-detail__product-cell">
170
+ <span class="location-detail__product-name">{{ row.product_name }}</span>
171
+ <span v-if="row.product_sku" class="location-detail__product-sku">
172
+ {{ row.product_sku }}
173
+ </span>
174
+ </div>
175
+ </template>
176
+ <template #cell-quantity="{ value }">
177
+ <strong>{{ formatQuantity(value) }}</strong>
178
+ </template>
179
+ <template #cell-is_primary="{ value }">
180
+ <InvBadge v-if="value" variant="default" size="sm">Primary</InvBadge>
181
+ </template>
182
+ </InvTable>
183
+ </InvCard>
184
+
185
+ <!-- 5. Recent movements card -->
186
+ <InvCard
187
+ title="Recent Movements"
188
+ compact
189
+ no-padding
190
+ collapsible
191
+ >
192
+ <div v-if="movementsLoading" class="location-detail__loading-inline">
193
+ <div class="location-detail__skeleton location-detail__skeleton--row" />
194
+ <div class="location-detail__skeleton location-detail__skeleton--row" />
195
+ </div>
196
+ <div v-else-if="movements.length === 0" class="location-detail__empty-inline">
197
+ <p>No recent movements.</p>
198
+ </div>
199
+ <div v-else class="location-detail__movements-list">
200
+ <div
201
+ v-for="movement in movements"
202
+ :key="movement.id"
203
+ class="location-detail__movement"
204
+ >
205
+ <div class="location-detail__movement-icon" :class="`location-detail__movement-icon--${movement.reason}`">
206
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
207
+ <path
208
+ v-if="movement.reason === 'placed' || movement.reason === 'received'"
209
+ d="M12 5v14M5 12l7 7 7-7"
210
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
211
+ />
212
+ <path
213
+ v-else-if="movement.reason === 'picked'"
214
+ d="M12 19V5M5 12l7-7 7 7"
215
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
216
+ />
217
+ <path
218
+ v-else-if="movement.reason === 'moved'"
219
+ d="M5 12h14M12 5l7 7-7 7"
220
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
221
+ />
222
+ <path
223
+ v-else
224
+ d="M4 4l16 16M20 4L4 20"
225
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
226
+ opacity="0.6"
227
+ />
228
+ </svg>
229
+ </div>
230
+ <div class="location-detail__movement-info">
231
+ <span class="location-detail__movement-primary">
232
+ <strong>{{ movement.reason_label }}</strong>
233
+ {{ movement.product_name }}
234
+ <span class="location-detail__movement-qty">
235
+ (qty: {{ formatQuantity(movement.quantity) }})
236
+ </span>
237
+ </span>
238
+ <span class="location-detail__movement-meta">
239
+ {{ movement.performer_name || 'System' }} &middot; {{ timeAgo(movement.performed_at) }}
240
+ </span>
241
+ </div>
242
+ </div>
243
+ </div>
244
+ </InvCard>
245
+
246
+ <!-- 6. Label preview card -->
247
+ <InvCard
248
+ title="Label Preview"
249
+ compact
250
+ collapsible
251
+ :collapsed="true"
252
+ >
253
+ <template #actions>
254
+ <InvButton variant="ghost" size="sm" @click="handlePrintLabel">
255
+ Print
256
+ </InvButton>
257
+ </template>
258
+ <div v-if="labelData" class="location-detail__label-preview">
259
+ <div
260
+ v-if="labelData.qr_code_svg"
261
+ class="location-detail__label-qr"
262
+ v-html="labelData.qr_code_svg"
263
+ />
264
+ <div class="location-detail__label-info">
265
+ <strong>{{ labelData.location.name }}</strong>
266
+ <span class="location-detail__label-code">{{ labelData.location.full_code }}</span>
267
+ <span class="location-detail__label-path">{{ labelData.path }}</span>
268
+ </div>
269
+ </div>
270
+ <div v-else class="location-detail__empty-inline">
271
+ <p>
272
+ <InvButton variant="secondary" size="sm" :loading="labelLoading" @click="loadLabel">
273
+ Load Label Preview
274
+ </InvButton>
275
+ </p>
276
+ </div>
277
+ </InvCard>
278
+ </template>
279
+
280
+ <!-- Edit form modal -->
281
+ <LocationForm
282
+ :show="showEditForm"
283
+ :location="location"
284
+ @update:show="showEditForm = $event"
285
+ @saved="handleLocationSaved"
286
+ />
287
+
288
+ <!-- Create child form modal -->
289
+ <LocationForm
290
+ :show="showCreateChildForm"
291
+ :parent-id="location?.id || null"
292
+ @update:show="showCreateChildForm = $event"
293
+ @saved="handleChildCreated"
294
+ />
295
+
296
+ <!-- Delete confirmation modal -->
297
+ <InvModal
298
+ :show="showDeleteConfirm"
299
+ title="Delete Location"
300
+ description="This action cannot be undone. All child locations and product associations will be removed."
301
+ size="sm"
302
+ @update:show="showDeleteConfirm = $event"
303
+ >
304
+ <p class="location-detail__delete-warning">
305
+ Are you sure you want to delete <strong>{{ location?.name }}</strong>?
306
+ </p>
307
+ <template #footer>
308
+ <InvButton variant="secondary" @click="showDeleteConfirm = false">
309
+ Cancel
310
+ </InvButton>
311
+ <InvButton variant="danger" :loading="deleting" @click="confirmDelete">
312
+ Delete Location
313
+ </InvButton>
314
+ </template>
315
+ </InvModal>
316
+ </div>
317
+ </template>
318
+
319
+ <script setup lang="ts">
320
+ import { ref, watch, onMounted } from 'vue'
321
+ import type { Location, LocationProduct, Movement, LabelData, Column } from '../../types'
322
+ import { useLocations } from '../../composables/useLocations'
323
+ import { useLocationProducts } from '../../composables/useLocationProducts'
324
+ import { useMovements } from '../../composables/useMovements'
325
+ import { useLabelPrinting } from '../../composables/useLabelPrinting'
326
+ import { useLocationStore } from '../../stores/locationStore'
327
+ import { formatQuantity, timeAgo } from '../../utils/codeFormatter'
328
+ import InvCard from '../shared/InvCard.vue'
329
+ import InvTable from '../shared/InvTable.vue'
330
+ import InvBadge from '../shared/InvBadge.vue'
331
+ import InvButton from '../shared/InvButton.vue'
332
+ import InvModal from '../shared/InvModal.vue'
333
+ import InvEmptyState from '../shared/InvEmptyState.vue'
334
+ import LocationBreadcrumb from './LocationBreadcrumb.vue'
335
+ import LocationTypeIcon from './LocationTypeIcon.vue'
336
+ import LocationCodeBadge from './LocationCodeBadge.vue'
337
+ import LocationForm from './LocationForm.vue'
338
+
339
+ const props = withDefaults(defineProps<{
340
+ locationCode?: string
341
+ locationId?: number
342
+ }>(), {
343
+ locationCode: undefined,
344
+ locationId: undefined,
345
+ })
346
+
347
+ const emit = defineEmits<{
348
+ navigate: [location: Location]
349
+ deleted: []
350
+ }>()
351
+
352
+ const locationStore = useLocationStore()
353
+ const { fetchLocation, fetchChildren, remove } = useLocations()
354
+ const { fetchProducts: fetchLocationProducts } = useLocationProducts()
355
+ const { fetchByLocation: fetchLocationMovements } = useMovements()
356
+ const { fetchLabel: fetchLocationLabel, printLabel: printLocationLabel } = useLabelPrinting()
357
+
358
+ const location = ref<Location | null>(null)
359
+ const loading = ref(false)
360
+ const error = ref<string | null>(null)
361
+
362
+ const children = ref<Location[]>([])
363
+ const childrenLoading = ref(false)
364
+
365
+ const products = ref<LocationProduct[]>([])
366
+ const productsLoading = ref(false)
367
+
368
+ const movements = ref<Movement[]>([])
369
+ const movementsLoading = ref(false)
370
+
371
+ const labelData = ref<LabelData | null>(null)
372
+ const labelLoading = ref(false)
373
+
374
+ const showEditForm = ref(false)
375
+ const showCreateChildForm = ref(false)
376
+ const showDeleteConfirm = ref(false)
377
+ const deleting = ref(false)
378
+
379
+ const childColumns: Column[] = [
380
+ { key: 'name', label: 'Name' },
381
+ { key: 'type_label', label: 'Type', width: '100px' },
382
+ { key: 'full_code', label: 'Code', width: '140px' },
383
+ { key: 'product_count', label: 'Products', width: '90px', align: 'right' },
384
+ ]
385
+
386
+ const productColumns: Column[] = [
387
+ { key: 'product_name', label: 'Product' },
388
+ { key: 'quantity', label: 'Qty', width: '80px', align: 'right' },
389
+ { key: 'is_primary', label: 'Primary', width: '90px', align: 'center' },
390
+ ]
391
+
392
+ function formatDate(dateStr: string): string {
393
+ return new Date(dateStr).toLocaleDateString(undefined, {
394
+ year: 'numeric',
395
+ month: 'short',
396
+ day: 'numeric',
397
+ hour: '2-digit',
398
+ minute: '2-digit',
399
+ })
400
+ }
401
+
402
+ async function loadLocation() {
403
+ if (!props.locationId && !props.locationCode) return
404
+
405
+ loading.value = true
406
+ error.value = null
407
+
408
+ try {
409
+ let loc: Location
410
+
411
+ if (props.locationId) {
412
+ loc = await fetchLocation(props.locationId)
413
+ } else if (props.locationCode) {
414
+ // Look up by code - use fetchLocation with the store lookup first
415
+ const found = findByCode(props.locationCode)
416
+ if (found) {
417
+ loc = await fetchLocation(found.id)
418
+ } else {
419
+ // Fallback: search by code is not directly supported, so rely on the store
420
+ error.value = 'Location not found'
421
+ return
422
+ }
423
+ } else {
424
+ return
425
+ }
426
+
427
+ location.value = loc
428
+ locationStore.selectLocation(loc.id)
429
+
430
+ // Load related data in parallel
431
+ await Promise.allSettled([
432
+ loadChildren(loc.id),
433
+ loadProducts(loc.id),
434
+ loadMovements(loc.id),
435
+ ])
436
+ } catch (err: any) {
437
+ error.value = err.response?.data?.message || 'Failed to load location'
438
+ } finally {
439
+ loading.value = false
440
+ }
441
+ }
442
+
443
+ function findByCode(code: string): Location | null {
444
+ const search = (nodes: Location[]): Location | null => {
445
+ for (const node of nodes) {
446
+ if (node.full_code === code) return node
447
+ if (node.children) {
448
+ const found = search(node.children)
449
+ if (found) return found
450
+ }
451
+ }
452
+ return null
453
+ }
454
+ return search(locationStore.roots)
455
+ }
456
+
457
+ async function loadChildren(locationId: number) {
458
+ childrenLoading.value = true
459
+ try {
460
+ children.value = await fetchChildren(locationId)
461
+ } catch {
462
+ children.value = []
463
+ } finally {
464
+ childrenLoading.value = false
465
+ }
466
+ }
467
+
468
+ async function loadProducts(locationId: number) {
469
+ productsLoading.value = true
470
+ try {
471
+ const { fetchProducts } = useLocationProducts()
472
+ await fetchProducts(locationId)
473
+ // The composable stores results internally, read from its ref
474
+ const productComposable = useLocationProducts()
475
+ await productComposable.fetchProducts(locationId)
476
+ products.value = productComposable.products.value
477
+ } catch {
478
+ products.value = []
479
+ } finally {
480
+ productsLoading.value = false
481
+ }
482
+ }
483
+
484
+ async function loadMovements(locationId: number) {
485
+ movementsLoading.value = true
486
+ try {
487
+ const movementComposable = useMovements()
488
+ await movementComposable.fetchByLocation(locationId)
489
+ movements.value = movementComposable.movements.value.slice(0, 10)
490
+ } catch {
491
+ movements.value = []
492
+ } finally {
493
+ movementsLoading.value = false
494
+ }
495
+ }
496
+
497
+ async function loadLabel() {
498
+ if (!location.value) return
499
+ labelLoading.value = true
500
+ try {
501
+ const data = await fetchLocationLabel(location.value.id)
502
+ labelData.value = data
503
+ } catch {
504
+ // Error handled by composable
505
+ } finally {
506
+ labelLoading.value = false
507
+ }
508
+ }
509
+
510
+ function handleBreadcrumbNavigate(segment: { id: number; full_code: string }) {
511
+ const loc = locationStore.findInTree(segment.id)
512
+ if (loc) {
513
+ emit('navigate', loc)
514
+ }
515
+ }
516
+
517
+ function handleChildClick(child: any) {
518
+ const loc = child as Location
519
+ emit('navigate', loc)
520
+ }
521
+
522
+ async function handlePrintLabel() {
523
+ if (!location.value) return
524
+ try {
525
+ await printLocationLabel(location.value.id)
526
+ } catch {
527
+ // Error handled by composable
528
+ }
529
+ }
530
+
531
+ function handleDelete() {
532
+ showDeleteConfirm.value = true
533
+ }
534
+
535
+ async function confirmDelete() {
536
+ if (!location.value) return
537
+ deleting.value = true
538
+ try {
539
+ await remove(location.value.id)
540
+ showDeleteConfirm.value = false
541
+ emit('deleted')
542
+ } catch {
543
+ // Error handled by composable
544
+ } finally {
545
+ deleting.value = false
546
+ }
547
+ }
548
+
549
+ function handleLocationSaved(updated: Location) {
550
+ location.value = updated
551
+ showEditForm.value = false
552
+ }
553
+
554
+ async function handleChildCreated(_newChild: Location) {
555
+ showCreateChildForm.value = false
556
+ if (location.value) {
557
+ await loadChildren(location.value.id)
558
+ }
559
+ }
560
+
561
+ // Watch for prop changes to reload
562
+ watch(
563
+ () => [props.locationId, props.locationCode],
564
+ () => {
565
+ labelData.value = null
566
+ loadLocation()
567
+ },
568
+ )
569
+
570
+ onMounted(() => {
571
+ loadLocation()
572
+ })
573
+ </script>
574
+
575
+ <style scoped>
576
+ .location-detail {
577
+ display: flex;
578
+ flex-direction: column;
579
+ gap: var(--space-4, 1rem);
580
+ padding: var(--space-4, 1rem);
581
+ overflow-y: auto;
582
+ flex: 1;
583
+ min-height: 0;
584
+ }
585
+
586
+ /* Header */
587
+ .location-detail__header {
588
+ display: flex;
589
+ flex-direction: column;
590
+ gap: var(--space-3, 0.75rem);
591
+ }
592
+
593
+ .location-detail__title-row {
594
+ display: flex;
595
+ align-items: flex-start;
596
+ justify-content: space-between;
597
+ gap: var(--space-4, 1rem);
598
+ flex-wrap: wrap;
599
+ }
600
+
601
+ .location-detail__title-area {
602
+ display: flex;
603
+ align-items: center;
604
+ gap: var(--space-2, 0.5rem);
605
+ flex-wrap: wrap;
606
+ min-width: 0;
607
+ }
608
+
609
+ .location-detail__name {
610
+ margin: 0;
611
+ font-size: var(--text-xl, 1.25rem);
612
+ font-weight: 700;
613
+ color: var(--admin-text-primary);
614
+ line-height: 1.3;
615
+ }
616
+
617
+ .location-detail__actions {
618
+ display: flex;
619
+ align-items: center;
620
+ gap: var(--space-2, 0.5rem);
621
+ flex-shrink: 0;
622
+ }
623
+
624
+ /* Info grid */
625
+ .location-detail__info-grid {
626
+ display: grid;
627
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
628
+ gap: var(--space-4, 1rem);
629
+ }
630
+
631
+ .location-detail__info-item {
632
+ display: flex;
633
+ flex-direction: column;
634
+ gap: var(--space-1, 0.25rem);
635
+ }
636
+
637
+ .location-detail__info-label {
638
+ font-size: var(--text-xs, 0.75rem);
639
+ font-weight: 600;
640
+ text-transform: uppercase;
641
+ letter-spacing: 0.05em;
642
+ color: var(--admin-text-tertiary);
643
+ }
644
+
645
+ .location-detail__info-value {
646
+ margin: 0;
647
+ font-size: var(--text-sm, 0.875rem);
648
+ color: var(--admin-text-primary);
649
+ line-height: 1.5;
650
+ }
651
+
652
+ /* Children */
653
+ .location-detail__child-cell {
654
+ display: flex;
655
+ align-items: center;
656
+ gap: var(--space-2, 0.5rem);
657
+ }
658
+
659
+ .location-detail__count {
660
+ color: var(--admin-text-tertiary);
661
+ }
662
+
663
+ /* Products */
664
+ .location-detail__product-count-label {
665
+ font-size: var(--text-sm, 0.875rem);
666
+ color: var(--admin-text-tertiary);
667
+ }
668
+
669
+ .location-detail__product-cell {
670
+ display: flex;
671
+ flex-direction: column;
672
+ gap: 1px;
673
+ }
674
+
675
+ .location-detail__product-name {
676
+ font-weight: 500;
677
+ color: var(--admin-text-primary);
678
+ }
679
+
680
+ .location-detail__product-sku {
681
+ font-size: var(--text-xs, 0.75rem);
682
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
683
+ color: var(--admin-text-tertiary);
684
+ }
685
+
686
+ /* Movements */
687
+ .location-detail__movements-list {
688
+ display: flex;
689
+ flex-direction: column;
690
+ }
691
+
692
+ .location-detail__movement {
693
+ display: flex;
694
+ align-items: flex-start;
695
+ gap: var(--space-3, 0.75rem);
696
+ padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
697
+ border-bottom: 1px solid var(--admin-border);
698
+ }
699
+
700
+ .location-detail__movement:last-child {
701
+ border-bottom: none;
702
+ }
703
+
704
+ .location-detail__movement-icon {
705
+ display: flex;
706
+ align-items: center;
707
+ justify-content: center;
708
+ width: 28px;
709
+ height: 28px;
710
+ border-radius: 50%;
711
+ flex-shrink: 0;
712
+ background: var(--admin-content-bg);
713
+ color: var(--admin-text-secondary);
714
+ }
715
+
716
+ .location-detail__movement-icon--placed,
717
+ .location-detail__movement-icon--received {
718
+ background: color-mix(in srgb, var(--color-success, #059669) 12%, transparent);
719
+ color: var(--color-success, #059669);
720
+ }
721
+
722
+ .location-detail__movement-icon--picked {
723
+ background: color-mix(in srgb, var(--color-warning, #d97706) 12%, transparent);
724
+ color: var(--color-warning, #d97706);
725
+ }
726
+
727
+ .location-detail__movement-icon--moved {
728
+ background: color-mix(in srgb, var(--color-info, #0891b2) 12%, transparent);
729
+ color: var(--color-info, #0891b2);
730
+ }
731
+
732
+ .location-detail__movement-info {
733
+ display: flex;
734
+ flex-direction: column;
735
+ gap: 2px;
736
+ min-width: 0;
737
+ }
738
+
739
+ .location-detail__movement-primary {
740
+ font-size: var(--text-sm, 0.875rem);
741
+ color: var(--admin-text-primary);
742
+ line-height: 1.4;
743
+ }
744
+
745
+ .location-detail__movement-qty {
746
+ color: var(--admin-text-tertiary);
747
+ }
748
+
749
+ .location-detail__movement-meta {
750
+ font-size: var(--text-xs, 0.75rem);
751
+ color: var(--admin-text-tertiary);
752
+ }
753
+
754
+ /* Label preview */
755
+ .location-detail__label-preview {
756
+ display: flex;
757
+ align-items: center;
758
+ gap: var(--space-4, 1rem);
759
+ padding: var(--space-2, 0.5rem);
760
+ }
761
+
762
+ .location-detail__label-qr {
763
+ flex-shrink: 0;
764
+ width: 80px;
765
+ height: 80px;
766
+ color: var(--admin-text-primary);
767
+ }
768
+
769
+ .location-detail__label-qr :deep(svg) {
770
+ width: 100%;
771
+ height: 100%;
772
+ }
773
+
774
+ .location-detail__label-info {
775
+ display: flex;
776
+ flex-direction: column;
777
+ gap: var(--space-1, 0.25rem);
778
+ font-size: var(--text-sm, 0.875rem);
779
+ color: var(--admin-text-primary);
780
+ }
781
+
782
+ .location-detail__label-code {
783
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
784
+ color: var(--admin-text-secondary);
785
+ }
786
+
787
+ .location-detail__label-path {
788
+ font-size: var(--text-xs, 0.75rem);
789
+ color: var(--admin-text-tertiary);
790
+ }
791
+
792
+ /* Delete */
793
+ .location-detail__delete-warning {
794
+ margin: 0;
795
+ font-size: var(--text-sm, 0.875rem);
796
+ color: var(--admin-text-primary);
797
+ line-height: 1.5;
798
+ }
799
+
800
+ /* Loading / Empty inline states */
801
+ .location-detail__loading {
802
+ display: flex;
803
+ flex-direction: column;
804
+ gap: var(--space-4, 1rem);
805
+ padding: var(--space-4, 1rem);
806
+ }
807
+
808
+ .location-detail__loading-header {
809
+ display: flex;
810
+ flex-direction: column;
811
+ gap: var(--space-3, 0.75rem);
812
+ }
813
+
814
+ .location-detail__loading-inline {
815
+ display: flex;
816
+ flex-direction: column;
817
+ gap: var(--space-2, 0.5rem);
818
+ padding: var(--space-4, 1rem);
819
+ }
820
+
821
+ .location-detail__empty-inline {
822
+ text-align: center;
823
+ padding: var(--space-6, 1.5rem) var(--space-4, 1rem);
824
+ color: var(--admin-text-tertiary);
825
+ font-size: var(--text-sm, 0.875rem);
826
+ }
827
+
828
+ .location-detail__empty-inline p {
829
+ margin: 0;
830
+ }
831
+
832
+ .location-detail__skeleton {
833
+ border-radius: 4px;
834
+ background: linear-gradient(
835
+ 90deg,
836
+ var(--admin-border) 25%,
837
+ var(--admin-content-bg) 50%,
838
+ var(--admin-border) 75%
839
+ );
840
+ background-size: 200% 100%;
841
+ animation: detail-shimmer 1.5s ease-in-out infinite;
842
+ }
843
+
844
+ .location-detail__skeleton--breadcrumb {
845
+ width: 200px;
846
+ height: 14px;
847
+ }
848
+
849
+ .location-detail__skeleton--title {
850
+ width: 300px;
851
+ height: 24px;
852
+ }
853
+
854
+ .location-detail__skeleton--badges {
855
+ width: 180px;
856
+ height: 18px;
857
+ }
858
+
859
+ .location-detail__skeleton--card {
860
+ height: 120px;
861
+ border-radius: 8px;
862
+ }
863
+
864
+ .location-detail__skeleton--row {
865
+ height: 40px;
866
+ }
867
+
868
+ @keyframes detail-shimmer {
869
+ 0% { background-position: 200% 0; }
870
+ 100% { background-position: -200% 0; }
871
+ }
872
+
873
+ .sr-only {
874
+ position: absolute;
875
+ width: 1px;
876
+ height: 1px;
877
+ padding: 0;
878
+ margin: -1px;
879
+ overflow: hidden;
880
+ clip: rect(0, 0, 0, 0);
881
+ white-space: nowrap;
882
+ border-width: 0;
883
+ }
884
+ </style>