@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,360 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<InvModal
|
|
3
|
+
:show="show"
|
|
4
|
+
:title="isEdit ? 'Edit Location' : 'Create Location'"
|
|
5
|
+
:description="isEdit ? 'Update this location\'s details.' : 'Add a new location to your inventory hierarchy.'"
|
|
6
|
+
size="md"
|
|
7
|
+
initial-focus-selector="input[data-field='name']"
|
|
8
|
+
@update:show="emit('update:show', $event)"
|
|
9
|
+
@close="handleClose"
|
|
10
|
+
>
|
|
11
|
+
<form
|
|
12
|
+
class="location-form"
|
|
13
|
+
@submit.prevent="handleSubmit"
|
|
14
|
+
>
|
|
15
|
+
<!-- Name -->
|
|
16
|
+
<InvInput
|
|
17
|
+
v-model="form.name"
|
|
18
|
+
label="Name"
|
|
19
|
+
placeholder="e.g. Warehouse 1, Rack A, Shelf 01"
|
|
20
|
+
:error="errors.name"
|
|
21
|
+
data-field="name"
|
|
22
|
+
@update:model-value="clearError('name')"
|
|
23
|
+
/>
|
|
24
|
+
|
|
25
|
+
<!-- Type -->
|
|
26
|
+
<InvSelect
|
|
27
|
+
v-model="form.type"
|
|
28
|
+
label="Type"
|
|
29
|
+
placeholder="Select location type..."
|
|
30
|
+
:options="typeOptions"
|
|
31
|
+
:error="errors.type"
|
|
32
|
+
:disabled="isEdit"
|
|
33
|
+
@update:model-value="clearError('type')"
|
|
34
|
+
/>
|
|
35
|
+
|
|
36
|
+
<!-- Code -->
|
|
37
|
+
<div class="location-form__field">
|
|
38
|
+
<InvInput
|
|
39
|
+
v-model="form.code"
|
|
40
|
+
label="Code (optional)"
|
|
41
|
+
placeholder="Auto-generated if blank"
|
|
42
|
+
:error="errors.code"
|
|
43
|
+
@update:model-value="handleCodeInput"
|
|
44
|
+
/>
|
|
45
|
+
<p v-if="codePreview" class="location-form__code-preview">
|
|
46
|
+
Full code: <code>{{ codePreview }}</code>
|
|
47
|
+
</p>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<!-- Description -->
|
|
51
|
+
<div class="location-form__field">
|
|
52
|
+
<label :for="descriptionId" class="location-form__label">
|
|
53
|
+
Description
|
|
54
|
+
</label>
|
|
55
|
+
<textarea
|
|
56
|
+
:id="descriptionId"
|
|
57
|
+
v-model="form.description"
|
|
58
|
+
class="location-form__textarea"
|
|
59
|
+
placeholder="Optional notes about this location..."
|
|
60
|
+
rows="3"
|
|
61
|
+
/>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<!-- Sort order -->
|
|
65
|
+
<InvInput
|
|
66
|
+
v-model="form.sort_order"
|
|
67
|
+
label="Sort order"
|
|
68
|
+
type="number"
|
|
69
|
+
placeholder="0"
|
|
70
|
+
/>
|
|
71
|
+
</form>
|
|
72
|
+
|
|
73
|
+
<template #footer>
|
|
74
|
+
<InvButton
|
|
75
|
+
variant="secondary"
|
|
76
|
+
@click="handleClose"
|
|
77
|
+
>
|
|
78
|
+
Cancel
|
|
79
|
+
</InvButton>
|
|
80
|
+
<InvButton
|
|
81
|
+
variant="primary"
|
|
82
|
+
:loading="saving"
|
|
83
|
+
:disabled="saving"
|
|
84
|
+
@click="handleSubmit"
|
|
85
|
+
>
|
|
86
|
+
{{ isEdit ? 'Save Changes' : 'Create Location' }}
|
|
87
|
+
</InvButton>
|
|
88
|
+
</template>
|
|
89
|
+
</InvModal>
|
|
90
|
+
</template>
|
|
91
|
+
|
|
92
|
+
<script setup lang="ts">
|
|
93
|
+
import { ref, computed, watch, reactive, useId } from 'vue'
|
|
94
|
+
import type { Location, LocationType, LocationTypeInfo, CreateLocationData, UpdateLocationData } from '../../types'
|
|
95
|
+
import { useLocations } from '../../composables/useLocations'
|
|
96
|
+
import InvModal from '../shared/InvModal.vue'
|
|
97
|
+
import InvInput from '../shared/InvInput.vue'
|
|
98
|
+
import InvSelect from '../shared/InvSelect.vue'
|
|
99
|
+
import InvButton from '../shared/InvButton.vue'
|
|
100
|
+
|
|
101
|
+
const props = withDefaults(defineProps<{
|
|
102
|
+
show: boolean
|
|
103
|
+
location?: Location | null
|
|
104
|
+
parentId?: number | null
|
|
105
|
+
}>(), {
|
|
106
|
+
location: null,
|
|
107
|
+
parentId: null,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const emit = defineEmits<{
|
|
111
|
+
'update:show': [value: boolean]
|
|
112
|
+
saved: [location: Location]
|
|
113
|
+
}>()
|
|
114
|
+
|
|
115
|
+
const uid = useId()
|
|
116
|
+
const descriptionId = `location-form-desc-${uid}`
|
|
117
|
+
|
|
118
|
+
const { create, update, fetchTypes } = useLocations()
|
|
119
|
+
|
|
120
|
+
const saving = ref(false)
|
|
121
|
+
const allTypes = ref<LocationTypeInfo[]>([])
|
|
122
|
+
|
|
123
|
+
const form = reactive({
|
|
124
|
+
name: '',
|
|
125
|
+
type: '' as string,
|
|
126
|
+
code: '',
|
|
127
|
+
description: '',
|
|
128
|
+
sort_order: 0 as number,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const errors = reactive({
|
|
132
|
+
name: '',
|
|
133
|
+
type: '',
|
|
134
|
+
code: '',
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const isEdit = computed(() => !!props.location)
|
|
138
|
+
|
|
139
|
+
// Compute parent code for the full code preview
|
|
140
|
+
const parentCode = computed(() => {
|
|
141
|
+
if (props.location?.full_code && props.location?.code) {
|
|
142
|
+
// For edit mode, strip the location's own code from the end
|
|
143
|
+
const fc = props.location.full_code
|
|
144
|
+
const ownCode = props.location.code
|
|
145
|
+
if (fc.endsWith(ownCode)) {
|
|
146
|
+
const prefix = fc.slice(0, fc.length - ownCode.length)
|
|
147
|
+
// Remove trailing separator
|
|
148
|
+
return prefix.replace(/-$/, '')
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// For create mode, we would need parent's full_code - show a partial preview
|
|
152
|
+
return null
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const codePreview = computed(() => {
|
|
156
|
+
const codeVal = form.code.trim()
|
|
157
|
+
if (!codeVal) return ''
|
|
158
|
+
if (parentCode.value) {
|
|
159
|
+
return `${parentCode.value}-${codeVal}`
|
|
160
|
+
}
|
|
161
|
+
return codeVal
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const typeOptions = computed(() => {
|
|
165
|
+
return allTypes.value.map(t => ({
|
|
166
|
+
value: t.value,
|
|
167
|
+
label: t.label,
|
|
168
|
+
}))
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// Validate code: alphanumeric + hyphens only
|
|
172
|
+
const CODE_REGEX = /^[a-zA-Z0-9-]*$/
|
|
173
|
+
|
|
174
|
+
function handleCodeInput(value: string | number) {
|
|
175
|
+
const strVal = String(value)
|
|
176
|
+
if (!CODE_REGEX.test(strVal)) {
|
|
177
|
+
errors.code = 'Only letters, numbers, and hyphens allowed'
|
|
178
|
+
} else {
|
|
179
|
+
errors.code = ''
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function clearError(field: keyof typeof errors) {
|
|
184
|
+
errors[field] = ''
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function validate(): boolean {
|
|
188
|
+
let valid = true
|
|
189
|
+
|
|
190
|
+
if (!form.name.trim()) {
|
|
191
|
+
errors.name = 'Name is required'
|
|
192
|
+
valid = false
|
|
193
|
+
} else if (form.name.trim().length > 255) {
|
|
194
|
+
errors.name = 'Name must be 255 characters or less'
|
|
195
|
+
valid = false
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!isEdit.value && !form.type) {
|
|
199
|
+
errors.type = 'Type is required'
|
|
200
|
+
valid = false
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (form.code && !CODE_REGEX.test(form.code)) {
|
|
204
|
+
errors.code = 'Only letters, numbers, and hyphens allowed'
|
|
205
|
+
valid = false
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return valid
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function handleSubmit() {
|
|
212
|
+
if (!validate()) return
|
|
213
|
+
|
|
214
|
+
saving.value = true
|
|
215
|
+
try {
|
|
216
|
+
let result: Location
|
|
217
|
+
|
|
218
|
+
if (isEdit.value && props.location) {
|
|
219
|
+
const data: UpdateLocationData = {
|
|
220
|
+
name: form.name.trim(),
|
|
221
|
+
description: form.description.trim() || undefined,
|
|
222
|
+
sort_order: form.sort_order,
|
|
223
|
+
}
|
|
224
|
+
if (form.code.trim()) {
|
|
225
|
+
data.code = form.code.trim()
|
|
226
|
+
}
|
|
227
|
+
result = await update(props.location.id, data)
|
|
228
|
+
} else {
|
|
229
|
+
const data: CreateLocationData = {
|
|
230
|
+
name: form.name.trim(),
|
|
231
|
+
type: form.type as LocationType,
|
|
232
|
+
code: form.code.trim() || undefined,
|
|
233
|
+
description: form.description.trim() || undefined,
|
|
234
|
+
parent_id: props.parentId,
|
|
235
|
+
sort_order: form.sort_order,
|
|
236
|
+
}
|
|
237
|
+
result = await create(data)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
emit('saved', result)
|
|
241
|
+
emit('update:show', false)
|
|
242
|
+
resetForm()
|
|
243
|
+
} catch (err: any) {
|
|
244
|
+
// Handle server-side validation errors
|
|
245
|
+
if (err.response?.data?.errors) {
|
|
246
|
+
const serverErrors = err.response.data.errors
|
|
247
|
+
if (serverErrors.name) errors.name = serverErrors.name[0]
|
|
248
|
+
if (serverErrors.type) errors.type = serverErrors.type[0]
|
|
249
|
+
if (serverErrors.code) errors.code = serverErrors.code[0]
|
|
250
|
+
}
|
|
251
|
+
} finally {
|
|
252
|
+
saving.value = false
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function handleClose() {
|
|
257
|
+
emit('update:show', false)
|
|
258
|
+
resetForm()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function resetForm() {
|
|
262
|
+
form.name = ''
|
|
263
|
+
form.type = ''
|
|
264
|
+
form.code = ''
|
|
265
|
+
form.description = ''
|
|
266
|
+
form.sort_order = 0
|
|
267
|
+
errors.name = ''
|
|
268
|
+
errors.type = ''
|
|
269
|
+
errors.code = ''
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function populateFromLocation(loc: Location) {
|
|
273
|
+
form.name = loc.name
|
|
274
|
+
form.type = loc.type
|
|
275
|
+
form.code = loc.code
|
|
276
|
+
form.description = loc.description || ''
|
|
277
|
+
form.sort_order = loc.sort_order
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Watch for show changes to load types and populate form
|
|
281
|
+
watch(() => props.show, async (isVisible) => {
|
|
282
|
+
if (isVisible) {
|
|
283
|
+
// Load types
|
|
284
|
+
if (allTypes.value.length === 0) {
|
|
285
|
+
try {
|
|
286
|
+
allTypes.value = await fetchTypes()
|
|
287
|
+
} catch {
|
|
288
|
+
// Error handled by composable
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Populate form for edit
|
|
293
|
+
if (props.location) {
|
|
294
|
+
populateFromLocation(props.location)
|
|
295
|
+
} else {
|
|
296
|
+
resetForm()
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
</script>
|
|
301
|
+
|
|
302
|
+
<style scoped>
|
|
303
|
+
.location-form {
|
|
304
|
+
display: flex;
|
|
305
|
+
flex-direction: column;
|
|
306
|
+
gap: var(--space-4, 1rem);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.location-form__field {
|
|
310
|
+
display: flex;
|
|
311
|
+
flex-direction: column;
|
|
312
|
+
gap: var(--space-1, 0.25rem);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.location-form__label {
|
|
316
|
+
font-size: var(--text-sm, 0.875rem);
|
|
317
|
+
font-weight: 500;
|
|
318
|
+
color: var(--admin-text-primary);
|
|
319
|
+
line-height: 1.4;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.location-form__textarea {
|
|
323
|
+
padding: var(--space-3, 0.75rem);
|
|
324
|
+
border: 1px solid var(--admin-border);
|
|
325
|
+
border-radius: 6px;
|
|
326
|
+
background: var(--admin-card-bg);
|
|
327
|
+
color: var(--admin-text-primary);
|
|
328
|
+
font-size: var(--text-sm, 0.875rem);
|
|
329
|
+
font-family: inherit;
|
|
330
|
+
line-height: 1.5;
|
|
331
|
+
resize: vertical;
|
|
332
|
+
outline: none;
|
|
333
|
+
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
|
334
|
+
min-height: 80px;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.location-form__textarea::placeholder {
|
|
338
|
+
color: var(--admin-text-tertiary);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.location-form__textarea:focus {
|
|
342
|
+
border-color: var(--color-primary, #2563eb);
|
|
343
|
+
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
.location-form__code-preview {
|
|
347
|
+
margin: var(--space-1, 0.25rem) 0 0;
|
|
348
|
+
font-size: var(--text-xs, 0.75rem);
|
|
349
|
+
color: var(--admin-text-tertiary);
|
|
350
|
+
line-height: 1.4;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.location-form__code-preview code {
|
|
354
|
+
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
|
|
355
|
+
padding: 1px 4px;
|
|
356
|
+
border-radius: 3px;
|
|
357
|
+
background: var(--admin-content-bg);
|
|
358
|
+
color: var(--admin-text-secondary);
|
|
359
|
+
}
|
|
360
|
+
</style>
|