@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,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>