@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,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' }} · {{ 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>
|