@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.
- package/package.json +47 -0
- package/src/api/client.ts +31 -0
- package/src/components/counting/CountEntryForm.vue +833 -0
- package/src/components/counting/CountReport.vue +385 -0
- package/src/components/counting/CountSessionDetail.vue +650 -0
- package/src/components/counting/CountSessionList.vue +683 -0
- package/src/components/dashboard/InventoryDashboard.vue +670 -0
- package/src/components/dashboard/UnlocatedProducts.vue +468 -0
- package/src/components/labels/LabelBatchPrint.vue +528 -0
- package/src/components/labels/LabelPreview.vue +293 -0
- package/src/components/locations/InventoryLocatorShell.vue +408 -0
- package/src/components/locations/LocationBreadcrumb.vue +144 -0
- package/src/components/locations/LocationCodeBadge.vue +46 -0
- package/src/components/locations/LocationDetail.vue +884 -0
- package/src/components/locations/LocationForm.vue +360 -0
- package/src/components/locations/LocationSearchInput.vue +428 -0
- package/src/components/locations/LocationTree.vue +156 -0
- package/src/components/locations/LocationTreeNode.vue +280 -0
- package/src/components/locations/LocationTypeIcon.vue +58 -0
- package/src/components/products/LocationProductAdd.vue +637 -0
- package/src/components/products/LocationProductList.vue +547 -0
- package/src/components/products/ProductLocationList.vue +215 -0
- package/src/components/products/QuickMoveModal.vue +592 -0
- package/src/components/scanning/ScanHistory.vue +146 -0
- package/src/components/scanning/ScanResult.vue +350 -0
- package/src/components/scanning/ScannerOverlay.vue +696 -0
- package/src/components/shared/InvBadge.vue +71 -0
- package/src/components/shared/InvButton.vue +206 -0
- package/src/components/shared/InvCard.vue +254 -0
- package/src/components/shared/InvEmptyState.vue +132 -0
- package/src/components/shared/InvInput.vue +125 -0
- package/src/components/shared/InvModal.vue +296 -0
- package/src/components/shared/InvSelect.vue +155 -0
- package/src/components/shared/InvTable.vue +288 -0
- package/src/composables/useCountSessions.ts +184 -0
- package/src/composables/useLabelPrinting.ts +71 -0
- package/src/composables/useLocationBreadcrumbs.ts +19 -0
- package/src/composables/useLocationProducts.ts +125 -0
- package/src/composables/useLocationSearch.ts +46 -0
- package/src/composables/useLocations.ts +159 -0
- package/src/composables/useMovements.ts +71 -0
- package/src/composables/useScanner.ts +83 -0
- package/src/env.d.ts +7 -0
- package/src/index.ts +46 -0
- package/src/plugin.ts +14 -0
- package/src/stores/countStore.ts +95 -0
- package/src/stores/locationStore.ts +113 -0
- package/src/stores/scannerStore.ts +51 -0
- package/src/types/index.ts +216 -0
- package/src/utils/codeFormatter.ts +29 -0
- package/src/utils/locationIcons.ts +64 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +37 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="label-preview">
|
|
3
|
+
<!-- Format selector -->
|
|
4
|
+
<div class="label-preview__controls">
|
|
5
|
+
<InvSelect
|
|
6
|
+
v-model="selectedFormat"
|
|
7
|
+
:options="formatOptions"
|
|
8
|
+
label="Format"
|
|
9
|
+
/>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<!-- Loading -->
|
|
13
|
+
<div v-if="loading" class="label-preview__loading" role="status">
|
|
14
|
+
<div class="label-preview__loading-spinner" aria-hidden="true" />
|
|
15
|
+
<span class="sr-only">Loading label...</span>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<!-- Error -->
|
|
19
|
+
<div v-else-if="error" class="label-preview__error" role="alert">
|
|
20
|
+
<p class="label-preview__error-text">{{ error }}</p>
|
|
21
|
+
<InvButton variant="secondary" size="sm" @click="loadLabel">
|
|
22
|
+
Retry
|
|
23
|
+
</InvButton>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<!-- Preview -->
|
|
27
|
+
<div
|
|
28
|
+
v-else-if="labelData"
|
|
29
|
+
class="label-preview__container"
|
|
30
|
+
>
|
|
31
|
+
<div
|
|
32
|
+
class="label-preview__label"
|
|
33
|
+
:style="labelDimensionStyle"
|
|
34
|
+
>
|
|
35
|
+
<!-- Path -->
|
|
36
|
+
<p class="label-preview__path">{{ labelData.path }}</p>
|
|
37
|
+
|
|
38
|
+
<!-- Code (bold) -->
|
|
39
|
+
<p class="label-preview__code">{{ labelData.location.full_code }}</p>
|
|
40
|
+
|
|
41
|
+
<!-- Name -->
|
|
42
|
+
<p class="label-preview__name">{{ labelData.location.name }}</p>
|
|
43
|
+
|
|
44
|
+
<!-- Barcode SVG -->
|
|
45
|
+
<div
|
|
46
|
+
v-if="labelData.barcode_svg"
|
|
47
|
+
class="label-preview__barcode"
|
|
48
|
+
aria-label="Barcode"
|
|
49
|
+
v-html="labelData.barcode_svg"
|
|
50
|
+
/>
|
|
51
|
+
|
|
52
|
+
<!-- QR code SVG -->
|
|
53
|
+
<div
|
|
54
|
+
v-if="labelData.qr_code_svg"
|
|
55
|
+
class="label-preview__qr"
|
|
56
|
+
aria-label="QR code"
|
|
57
|
+
v-html="labelData.qr_code_svg"
|
|
58
|
+
/>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<!-- Print button -->
|
|
63
|
+
<div v-if="labelData" class="label-preview__print">
|
|
64
|
+
<InvButton
|
|
65
|
+
variant="primary"
|
|
66
|
+
size="md"
|
|
67
|
+
:loading="printing"
|
|
68
|
+
@click="handlePrint"
|
|
69
|
+
>
|
|
70
|
+
<template #icon-left>
|
|
71
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
72
|
+
<rect x="4" y="1" width="8" height="5" rx="1" stroke="currentColor" stroke-width="1.5" />
|
|
73
|
+
<rect x="2" y="6" width="12" height="6" rx="1" stroke="currentColor" stroke-width="1.5" />
|
|
74
|
+
<rect x="5" y="10" width="6" height="5" rx="1" stroke="currentColor" stroke-width="1.5" />
|
|
75
|
+
</svg>
|
|
76
|
+
</template>
|
|
77
|
+
Print Label
|
|
78
|
+
</InvButton>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</template>
|
|
82
|
+
|
|
83
|
+
<script setup lang="ts">
|
|
84
|
+
import { ref, computed, onMounted, watch } from 'vue'
|
|
85
|
+
import { useLabelPrinting } from '../../composables/useLabelPrinting'
|
|
86
|
+
import type { LabelFormat } from '../../types'
|
|
87
|
+
import InvButton from '../shared/InvButton.vue'
|
|
88
|
+
import InvSelect from '../shared/InvSelect.vue'
|
|
89
|
+
|
|
90
|
+
const props = withDefaults(defineProps<{
|
|
91
|
+
locationId: number
|
|
92
|
+
format?: LabelFormat
|
|
93
|
+
}>(), {
|
|
94
|
+
format: 'standard',
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const emit = defineEmits<{
|
|
98
|
+
print: []
|
|
99
|
+
}>()
|
|
100
|
+
|
|
101
|
+
const { labelData, loading, error, fetchLabel, printLabel } = useLabelPrinting()
|
|
102
|
+
|
|
103
|
+
const selectedFormat = ref<string>(props.format)
|
|
104
|
+
const printing = ref(false)
|
|
105
|
+
|
|
106
|
+
const formatOptions = [
|
|
107
|
+
{ value: 'standard', label: 'Standard' },
|
|
108
|
+
{ value: 'small', label: 'Small' },
|
|
109
|
+
{ value: 'pallet', label: 'Pallet' },
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
const labelDimensionStyle = computed(() => {
|
|
113
|
+
if (!labelData.value?.size) return {}
|
|
114
|
+
return {
|
|
115
|
+
width: labelData.value.size.width,
|
|
116
|
+
height: labelData.value.size.height,
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
onMounted(() => loadLabel())
|
|
121
|
+
|
|
122
|
+
watch(selectedFormat, () => loadLabel())
|
|
123
|
+
|
|
124
|
+
async function loadLabel() {
|
|
125
|
+
try {
|
|
126
|
+
await fetchLabel(props.locationId, selectedFormat.value as LabelFormat)
|
|
127
|
+
} catch {
|
|
128
|
+
// Error is displayed in template
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function handlePrint() {
|
|
133
|
+
printing.value = true
|
|
134
|
+
try {
|
|
135
|
+
await printLabel(props.locationId, selectedFormat.value as LabelFormat)
|
|
136
|
+
emit('print')
|
|
137
|
+
} catch {
|
|
138
|
+
// Error handling is in the composable
|
|
139
|
+
} finally {
|
|
140
|
+
printing.value = false
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
</script>
|
|
144
|
+
|
|
145
|
+
<style scoped>
|
|
146
|
+
.label-preview {
|
|
147
|
+
display: flex;
|
|
148
|
+
flex-direction: column;
|
|
149
|
+
gap: var(--space-4, 1rem);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.label-preview__controls {
|
|
153
|
+
max-width: 200px;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.label-preview__loading {
|
|
157
|
+
display: flex;
|
|
158
|
+
align-items: center;
|
|
159
|
+
justify-content: center;
|
|
160
|
+
padding: var(--space-8, 2rem);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.label-preview__loading-spinner {
|
|
164
|
+
width: 32px;
|
|
165
|
+
height: 32px;
|
|
166
|
+
border: 3px solid var(--admin-border);
|
|
167
|
+
border-top-color: var(--color-primary, #2563eb);
|
|
168
|
+
border-radius: 50%;
|
|
169
|
+
animation: label-spin 0.8s linear infinite;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
@keyframes label-spin {
|
|
173
|
+
from { transform: rotate(0deg); }
|
|
174
|
+
to { transform: rotate(360deg); }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.label-preview__error {
|
|
178
|
+
display: flex;
|
|
179
|
+
flex-direction: column;
|
|
180
|
+
align-items: center;
|
|
181
|
+
gap: var(--space-3, 0.75rem);
|
|
182
|
+
padding: var(--space-6, 1.5rem);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.label-preview__error-text {
|
|
186
|
+
margin: 0;
|
|
187
|
+
font-size: var(--text-sm, 0.875rem);
|
|
188
|
+
color: var(--color-error, #dc2626);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.label-preview__container {
|
|
192
|
+
display: flex;
|
|
193
|
+
justify-content: center;
|
|
194
|
+
padding: var(--space-4, 1rem);
|
|
195
|
+
background: var(--admin-content-bg);
|
|
196
|
+
border-radius: 8px;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.label-preview__label {
|
|
200
|
+
display: flex;
|
|
201
|
+
flex-direction: column;
|
|
202
|
+
align-items: center;
|
|
203
|
+
gap: var(--space-2, 0.5rem);
|
|
204
|
+
padding: var(--space-4, 1rem);
|
|
205
|
+
background: #fff;
|
|
206
|
+
border: 2px solid var(--admin-border);
|
|
207
|
+
border-radius: 4px;
|
|
208
|
+
color: #000;
|
|
209
|
+
transform: scale(2);
|
|
210
|
+
transform-origin: top center;
|
|
211
|
+
margin-bottom: 100%;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.label-preview__path {
|
|
215
|
+
margin: 0;
|
|
216
|
+
font-size: 7px;
|
|
217
|
+
color: #666;
|
|
218
|
+
text-align: center;
|
|
219
|
+
line-height: 1.3;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.label-preview__code {
|
|
223
|
+
margin: 0;
|
|
224
|
+
font-size: 12px;
|
|
225
|
+
font-weight: 700;
|
|
226
|
+
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
|
227
|
+
text-align: center;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.label-preview__name {
|
|
231
|
+
margin: 0;
|
|
232
|
+
font-size: 8px;
|
|
233
|
+
color: #333;
|
|
234
|
+
text-align: center;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.label-preview__barcode {
|
|
238
|
+
width: 100%;
|
|
239
|
+
display: flex;
|
|
240
|
+
justify-content: center;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.label-preview__barcode :deep(svg) {
|
|
244
|
+
max-width: 100%;
|
|
245
|
+
height: auto;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.label-preview__qr {
|
|
249
|
+
display: flex;
|
|
250
|
+
justify-content: center;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.label-preview__qr :deep(svg) {
|
|
254
|
+
width: 48px;
|
|
255
|
+
height: 48px;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.label-preview__print {
|
|
259
|
+
display: flex;
|
|
260
|
+
justify-content: center;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.sr-only {
|
|
264
|
+
position: absolute;
|
|
265
|
+
width: 1px;
|
|
266
|
+
height: 1px;
|
|
267
|
+
padding: 0;
|
|
268
|
+
margin: -1px;
|
|
269
|
+
overflow: hidden;
|
|
270
|
+
clip: rect(0, 0, 0, 0);
|
|
271
|
+
white-space: nowrap;
|
|
272
|
+
border-width: 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
@media print {
|
|
276
|
+
.label-preview__controls,
|
|
277
|
+
.label-preview__print {
|
|
278
|
+
display: none;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.label-preview__label {
|
|
282
|
+
transform: none;
|
|
283
|
+
margin-bottom: 0;
|
|
284
|
+
border: none;
|
|
285
|
+
box-shadow: none;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.label-preview__container {
|
|
289
|
+
background: none;
|
|
290
|
+
padding: 0;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
</style>
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="inv-shell">
|
|
3
|
+
<!-- Header bar -->
|
|
4
|
+
<header class="inv-shell__header">
|
|
5
|
+
<div class="inv-shell__header-left">
|
|
6
|
+
<button
|
|
7
|
+
type="button"
|
|
8
|
+
class="inv-shell__menu-toggle"
|
|
9
|
+
:aria-label="drawerOpen ? 'Close sidebar' : 'Open sidebar'"
|
|
10
|
+
:aria-expanded="drawerOpen"
|
|
11
|
+
@click="drawerOpen = !drawerOpen"
|
|
12
|
+
>
|
|
13
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
14
|
+
<path d="M3 12h18M3 6h18M3 18h18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
15
|
+
</svg>
|
|
16
|
+
</button>
|
|
17
|
+
<h1 class="inv-shell__title">Inventory Locations</h1>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<div class="inv-shell__header-actions">
|
|
21
|
+
<div class="inv-shell__search-wrapper">
|
|
22
|
+
<LocationSearchInput
|
|
23
|
+
v-model="searchQuery"
|
|
24
|
+
placeholder="Search locations..."
|
|
25
|
+
@select="handleSearchSelect"
|
|
26
|
+
/>
|
|
27
|
+
</div>
|
|
28
|
+
<InvButton variant="primary" size="sm" @click="showCreateForm = true">
|
|
29
|
+
<template #icon-left>
|
|
30
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
31
|
+
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
32
|
+
</svg>
|
|
33
|
+
</template>
|
|
34
|
+
New
|
|
35
|
+
</InvButton>
|
|
36
|
+
<InvButton variant="secondary" size="sm" @click="handleScan">
|
|
37
|
+
<template #icon-left>
|
|
38
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
39
|
+
<path d="M3 7V5a2 2 0 012-2h2M17 3h2a2 2 0 012 2v2M21 17v2a2 2 0 01-2 2h-2M7 21H5a2 2 0 01-2-2v-2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
40
|
+
<path d="M7 8h10M7 12h10M7 16h6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
|
41
|
+
</svg>
|
|
42
|
+
</template>
|
|
43
|
+
Scan
|
|
44
|
+
</InvButton>
|
|
45
|
+
</div>
|
|
46
|
+
</header>
|
|
47
|
+
|
|
48
|
+
<div class="inv-shell__body">
|
|
49
|
+
<!-- Drawer backdrop (tablet/mobile) -->
|
|
50
|
+
<Transition name="inv-shell-backdrop">
|
|
51
|
+
<div
|
|
52
|
+
v-if="drawerOpen && isCompact"
|
|
53
|
+
class="inv-shell__backdrop"
|
|
54
|
+
@click="drawerOpen = false"
|
|
55
|
+
/>
|
|
56
|
+
</Transition>
|
|
57
|
+
|
|
58
|
+
<!-- Sidebar / Tree panel -->
|
|
59
|
+
<aside
|
|
60
|
+
class="inv-shell__sidebar"
|
|
61
|
+
:class="{
|
|
62
|
+
'inv-shell__sidebar--open': drawerOpen,
|
|
63
|
+
'inv-shell__sidebar--compact': isCompact,
|
|
64
|
+
}"
|
|
65
|
+
:aria-hidden="isCompact && !drawerOpen ? 'true' : undefined"
|
|
66
|
+
>
|
|
67
|
+
<LocationTree
|
|
68
|
+
@select="handleTreeSelect"
|
|
69
|
+
@create="showCreateForm = true"
|
|
70
|
+
/>
|
|
71
|
+
</aside>
|
|
72
|
+
|
|
73
|
+
<!-- Detail panel -->
|
|
74
|
+
<main class="inv-shell__detail">
|
|
75
|
+
<LocationDetail
|
|
76
|
+
v-if="locationStore.selectedLocationId"
|
|
77
|
+
:location-id="locationStore.selectedLocationId"
|
|
78
|
+
@navigate="handleNavigate"
|
|
79
|
+
@deleted="handleDeleted"
|
|
80
|
+
/>
|
|
81
|
+
<InvEmptyState
|
|
82
|
+
v-else
|
|
83
|
+
title="Select a location"
|
|
84
|
+
description="Choose a location from the tree to view its details, products, and movements."
|
|
85
|
+
/>
|
|
86
|
+
</main>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<!-- Create location modal -->
|
|
90
|
+
<LocationForm
|
|
91
|
+
:show="showCreateForm"
|
|
92
|
+
@update:show="showCreateForm = $event"
|
|
93
|
+
@saved="handleLocationCreated"
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
</template>
|
|
97
|
+
|
|
98
|
+
<script setup lang="ts">
|
|
99
|
+
import { ref, computed, onMounted, onUnmounted, watch, inject } from 'vue'
|
|
100
|
+
import type { Location } from '../../types'
|
|
101
|
+
import { useLocationStore } from '../../stores/locationStore'
|
|
102
|
+
import InvButton from '../shared/InvButton.vue'
|
|
103
|
+
import InvEmptyState from '../shared/InvEmptyState.vue'
|
|
104
|
+
import LocationSearchInput from './LocationSearchInput.vue'
|
|
105
|
+
import LocationTree from './LocationTree.vue'
|
|
106
|
+
import LocationDetail from './LocationDetail.vue'
|
|
107
|
+
import LocationForm from './LocationForm.vue'
|
|
108
|
+
|
|
109
|
+
const locationStore = useLocationStore()
|
|
110
|
+
const router = inject<any>('router', null)
|
|
111
|
+
const route = inject<any>('route', null)
|
|
112
|
+
|
|
113
|
+
const searchQuery = ref('')
|
|
114
|
+
const showCreateForm = ref(false)
|
|
115
|
+
const drawerOpen = ref(true)
|
|
116
|
+
const windowWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1200)
|
|
117
|
+
|
|
118
|
+
const isCompact = computed(() => windowWidth.value < 1024)
|
|
119
|
+
|
|
120
|
+
function handleResize() {
|
|
121
|
+
windowWidth.value = window.innerWidth
|
|
122
|
+
// Auto-open sidebar on desktop
|
|
123
|
+
if (!isCompact.value) {
|
|
124
|
+
drawerOpen.value = true
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function handleSearchSelect(location: Location) {
|
|
129
|
+
searchQuery.value = ''
|
|
130
|
+
selectAndNavigate(location)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function handleTreeSelect(location: Location) {
|
|
134
|
+
selectAndNavigate(location)
|
|
135
|
+
// Close drawer on tablet/mobile after selection
|
|
136
|
+
if (isCompact.value) {
|
|
137
|
+
drawerOpen.value = false
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function handleNavigate(location: Location) {
|
|
142
|
+
selectAndNavigate(location)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function handleDeleted() {
|
|
146
|
+
locationStore.selectLocation(null)
|
|
147
|
+
updateUrl(null)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function handleLocationCreated(location: Location) {
|
|
151
|
+
showCreateForm.value = false
|
|
152
|
+
selectAndNavigate(location)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function handleScan() {
|
|
156
|
+
// Emit or navigate to scanner view
|
|
157
|
+
if (router) {
|
|
158
|
+
router.push({ name: 'inventory-scan' })
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function selectAndNavigate(location: Location) {
|
|
163
|
+
locationStore.selectLocation(location.id)
|
|
164
|
+
// Expand all ancestors
|
|
165
|
+
expandAncestors(location)
|
|
166
|
+
updateUrl(location.full_code)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function expandAncestors(location: Location) {
|
|
170
|
+
if (location.path) {
|
|
171
|
+
for (const segment of location.path) {
|
|
172
|
+
if (!locationStore.expandedNodeIds.has(segment.id)) {
|
|
173
|
+
locationStore.toggleExpand(segment.id)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function updateUrl(code: string | null) {
|
|
180
|
+
if (!router) return
|
|
181
|
+
try {
|
|
182
|
+
if (code) {
|
|
183
|
+
router.replace({ name: 'inventory-location', params: { code } })
|
|
184
|
+
} else {
|
|
185
|
+
router.replace({ name: 'inventory-locations' })
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
// Route may not exist, silently ignore
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function readRouteCode(): string | null {
|
|
193
|
+
if (route?.params?.code) {
|
|
194
|
+
return route.params.code as string
|
|
195
|
+
}
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// URL sync: select location from route on mount
|
|
200
|
+
function syncFromRoute() {
|
|
201
|
+
const code = readRouteCode()
|
|
202
|
+
if (code) {
|
|
203
|
+
// Find by code in the tree
|
|
204
|
+
const found = findByCode(code)
|
|
205
|
+
if (found) {
|
|
206
|
+
locationStore.selectLocation(found.id)
|
|
207
|
+
expandAncestors(found)
|
|
208
|
+
}
|
|
209
|
+
// If not found in tree yet, LocationDetail will handle loading by code
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function findByCode(code: string): Location | null {
|
|
214
|
+
const search = (nodes: Location[]): Location | null => {
|
|
215
|
+
for (const node of nodes) {
|
|
216
|
+
if (node.full_code === code) return node
|
|
217
|
+
if (node.children) {
|
|
218
|
+
const found = search(node.children)
|
|
219
|
+
if (found) return found
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return null
|
|
223
|
+
}
|
|
224
|
+
return search(locationStore.roots)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Watch route changes
|
|
228
|
+
if (route) {
|
|
229
|
+
watch(
|
|
230
|
+
() => route.params?.code,
|
|
231
|
+
(newCode) => {
|
|
232
|
+
if (newCode) {
|
|
233
|
+
const found = findByCode(newCode as string)
|
|
234
|
+
if (found) {
|
|
235
|
+
locationStore.selectLocation(found.id)
|
|
236
|
+
expandAncestors(found)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
onMounted(() => {
|
|
244
|
+
window.addEventListener('resize', handleResize)
|
|
245
|
+
handleResize()
|
|
246
|
+
syncFromRoute()
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
onUnmounted(() => {
|
|
250
|
+
window.removeEventListener('resize', handleResize)
|
|
251
|
+
})
|
|
252
|
+
</script>
|
|
253
|
+
|
|
254
|
+
<style scoped>
|
|
255
|
+
.inv-shell {
|
|
256
|
+
display: flex;
|
|
257
|
+
flex-direction: column;
|
|
258
|
+
height: 100%;
|
|
259
|
+
min-height: 0;
|
|
260
|
+
background: var(--admin-content-bg);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/* Header */
|
|
264
|
+
.inv-shell__header {
|
|
265
|
+
display: flex;
|
|
266
|
+
align-items: center;
|
|
267
|
+
justify-content: space-between;
|
|
268
|
+
gap: var(--space-4, 1rem);
|
|
269
|
+
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
|
|
270
|
+
background: var(--admin-card-bg);
|
|
271
|
+
border-bottom: 1px solid var(--admin-border);
|
|
272
|
+
flex-shrink: 0;
|
|
273
|
+
z-index: 10;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.inv-shell__header-left {
|
|
277
|
+
display: flex;
|
|
278
|
+
align-items: center;
|
|
279
|
+
gap: var(--space-3, 0.75rem);
|
|
280
|
+
min-width: 0;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.inv-shell__menu-toggle {
|
|
284
|
+
display: none;
|
|
285
|
+
align-items: center;
|
|
286
|
+
justify-content: center;
|
|
287
|
+
width: 36px;
|
|
288
|
+
height: 36px;
|
|
289
|
+
padding: 0;
|
|
290
|
+
border: none;
|
|
291
|
+
border-radius: 6px;
|
|
292
|
+
background: transparent;
|
|
293
|
+
color: var(--admin-text-secondary);
|
|
294
|
+
cursor: pointer;
|
|
295
|
+
transition: background-color 0.15s ease, color 0.15s ease;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.inv-shell__menu-toggle:hover {
|
|
299
|
+
background: var(--admin-content-bg);
|
|
300
|
+
color: var(--admin-text-primary);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.inv-shell__menu-toggle:focus-visible {
|
|
304
|
+
outline: 2px solid var(--admin-focus-ring, #2563eb);
|
|
305
|
+
outline-offset: 2px;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
@media (max-width: 1023px) {
|
|
309
|
+
.inv-shell__menu-toggle {
|
|
310
|
+
display: flex;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.inv-shell__title {
|
|
315
|
+
margin: 0;
|
|
316
|
+
font-size: var(--text-lg, 1.125rem);
|
|
317
|
+
font-weight: 600;
|
|
318
|
+
color: var(--admin-text-primary);
|
|
319
|
+
line-height: 1.3;
|
|
320
|
+
white-space: nowrap;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.inv-shell__header-actions {
|
|
324
|
+
display: flex;
|
|
325
|
+
align-items: center;
|
|
326
|
+
gap: var(--space-2, 0.5rem);
|
|
327
|
+
flex-shrink: 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.inv-shell__search-wrapper {
|
|
331
|
+
width: 260px;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
@media (max-width: 767px) {
|
|
335
|
+
.inv-shell__search-wrapper {
|
|
336
|
+
width: 160px;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.inv-shell__title {
|
|
340
|
+
font-size: var(--text-base, 1rem);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/* Body */
|
|
345
|
+
.inv-shell__body {
|
|
346
|
+
display: flex;
|
|
347
|
+
flex: 1;
|
|
348
|
+
min-height: 0;
|
|
349
|
+
position: relative;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/* Backdrop for compact mode */
|
|
353
|
+
.inv-shell__backdrop {
|
|
354
|
+
position: fixed;
|
|
355
|
+
inset: 0;
|
|
356
|
+
z-index: 20;
|
|
357
|
+
background: rgba(0, 0, 0, 0.4);
|
|
358
|
+
backdrop-filter: blur(2px);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.inv-shell-backdrop-enter-active,
|
|
362
|
+
.inv-shell-backdrop-leave-active {
|
|
363
|
+
transition: opacity 0.2s ease;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
.inv-shell-backdrop-enter-from,
|
|
367
|
+
.inv-shell-backdrop-leave-to {
|
|
368
|
+
opacity: 0;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/* Sidebar */
|
|
372
|
+
.inv-shell__sidebar {
|
|
373
|
+
width: 300px;
|
|
374
|
+
min-width: 300px;
|
|
375
|
+
display: flex;
|
|
376
|
+
flex-direction: column;
|
|
377
|
+
background: var(--admin-card-bg);
|
|
378
|
+
border-right: 1px solid var(--admin-border);
|
|
379
|
+
overflow: hidden;
|
|
380
|
+
flex-shrink: 0;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.inv-shell__sidebar--compact {
|
|
384
|
+
position: fixed;
|
|
385
|
+
top: 0;
|
|
386
|
+
left: 0;
|
|
387
|
+
bottom: 0;
|
|
388
|
+
z-index: 30;
|
|
389
|
+
transform: translateX(-100%);
|
|
390
|
+
transition: transform 0.25s ease;
|
|
391
|
+
box-shadow: none;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.inv-shell__sidebar--compact.inv-shell__sidebar--open {
|
|
395
|
+
transform: translateX(0);
|
|
396
|
+
box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/* Detail panel */
|
|
400
|
+
.inv-shell__detail {
|
|
401
|
+
flex: 1;
|
|
402
|
+
min-width: 0;
|
|
403
|
+
min-height: 0;
|
|
404
|
+
display: flex;
|
|
405
|
+
flex-direction: column;
|
|
406
|
+
overflow: hidden;
|
|
407
|
+
}
|
|
408
|
+
</style>
|