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