@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,288 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="inv-table-wrapper">
|
|
3
|
+
<table class="inv-table" role="grid" :aria-busy="loading">
|
|
4
|
+
<thead class="inv-table__head">
|
|
5
|
+
<tr>
|
|
6
|
+
<th
|
|
7
|
+
v-for="col in columns"
|
|
8
|
+
:key="col.key"
|
|
9
|
+
class="inv-table__th"
|
|
10
|
+
:class="[
|
|
11
|
+
`inv-table__th--${col.align || 'left'}`,
|
|
12
|
+
{ 'inv-table__th--sortable': col.sortable },
|
|
13
|
+
]"
|
|
14
|
+
:style="col.width ? { width: col.width } : undefined"
|
|
15
|
+
:aria-sort="getSortAria(col)"
|
|
16
|
+
scope="col"
|
|
17
|
+
>
|
|
18
|
+
<button
|
|
19
|
+
v-if="col.sortable"
|
|
20
|
+
type="button"
|
|
21
|
+
class="inv-table__sort-btn"
|
|
22
|
+
:class="{ 'inv-table__sort-btn--active': sortKey === col.key }"
|
|
23
|
+
@click="handleSort(col)"
|
|
24
|
+
>
|
|
25
|
+
<span>{{ col.label }}</span>
|
|
26
|
+
<svg
|
|
27
|
+
class="inv-table__sort-icon"
|
|
28
|
+
width="14"
|
|
29
|
+
height="14"
|
|
30
|
+
viewBox="0 0 14 14"
|
|
31
|
+
fill="none"
|
|
32
|
+
aria-hidden="true"
|
|
33
|
+
>
|
|
34
|
+
<path
|
|
35
|
+
v-if="sortKey === col.key && sortDir === 'asc'"
|
|
36
|
+
d="M7 3L11 8H3L7 3Z"
|
|
37
|
+
fill="currentColor"
|
|
38
|
+
/>
|
|
39
|
+
<path
|
|
40
|
+
v-else-if="sortKey === col.key && sortDir === 'desc'"
|
|
41
|
+
d="M7 11L3 6H11L7 11Z"
|
|
42
|
+
fill="currentColor"
|
|
43
|
+
/>
|
|
44
|
+
<template v-else>
|
|
45
|
+
<path d="M7 3L10 7H4L7 3Z" fill="currentColor" opacity="0.3" />
|
|
46
|
+
<path d="M7 11L4 7H10L7 11Z" fill="currentColor" opacity="0.3" />
|
|
47
|
+
</template>
|
|
48
|
+
</svg>
|
|
49
|
+
</button>
|
|
50
|
+
<span v-else>{{ col.label }}</span>
|
|
51
|
+
</th>
|
|
52
|
+
</tr>
|
|
53
|
+
</thead>
|
|
54
|
+
|
|
55
|
+
<tbody class="inv-table__body">
|
|
56
|
+
<!-- Loading skeleton -->
|
|
57
|
+
<template v-if="loading">
|
|
58
|
+
<tr
|
|
59
|
+
v-for="row in 5"
|
|
60
|
+
:key="`skeleton-${row}`"
|
|
61
|
+
class="inv-table__row inv-table__row--skeleton"
|
|
62
|
+
>
|
|
63
|
+
<td
|
|
64
|
+
v-for="col in columns"
|
|
65
|
+
:key="`skeleton-${row}-${col.key}`"
|
|
66
|
+
class="inv-table__td"
|
|
67
|
+
>
|
|
68
|
+
<div class="inv-table__skeleton-bar" />
|
|
69
|
+
</td>
|
|
70
|
+
</tr>
|
|
71
|
+
</template>
|
|
72
|
+
|
|
73
|
+
<!-- Empty state -->
|
|
74
|
+
<template v-else-if="data.length === 0">
|
|
75
|
+
<tr>
|
|
76
|
+
<td :colspan="columns.length" class="inv-table__td inv-table__td--empty">
|
|
77
|
+
<slot name="empty">
|
|
78
|
+
<span class="inv-table__empty-text">{{ emptyMessage }}</span>
|
|
79
|
+
</slot>
|
|
80
|
+
</td>
|
|
81
|
+
</tr>
|
|
82
|
+
</template>
|
|
83
|
+
|
|
84
|
+
<!-- Data rows -->
|
|
85
|
+
<template v-else>
|
|
86
|
+
<tr
|
|
87
|
+
v-for="(row, index) in data"
|
|
88
|
+
:key="index"
|
|
89
|
+
class="inv-table__row"
|
|
90
|
+
:class="{
|
|
91
|
+
'inv-table__row--clickable': clickable,
|
|
92
|
+
}"
|
|
93
|
+
:tabindex="clickable ? 0 : undefined"
|
|
94
|
+
:role="clickable ? 'button' : undefined"
|
|
95
|
+
@click="clickable ? emit('row-click', row) : undefined"
|
|
96
|
+
@keydown.enter="clickable ? emit('row-click', row) : undefined"
|
|
97
|
+
@keydown.space.prevent="clickable ? emit('row-click', row) : undefined"
|
|
98
|
+
>
|
|
99
|
+
<td
|
|
100
|
+
v-for="col in columns"
|
|
101
|
+
:key="col.key"
|
|
102
|
+
class="inv-table__td"
|
|
103
|
+
:class="[`inv-table__td--${col.align || 'left'}`]"
|
|
104
|
+
>
|
|
105
|
+
<slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]">
|
|
106
|
+
{{ row[col.key] ?? '' }}
|
|
107
|
+
</slot>
|
|
108
|
+
</td>
|
|
109
|
+
</tr>
|
|
110
|
+
</template>
|
|
111
|
+
</tbody>
|
|
112
|
+
</table>
|
|
113
|
+
</div>
|
|
114
|
+
</template>
|
|
115
|
+
|
|
116
|
+
<script setup lang="ts">
|
|
117
|
+
import type { Column } from '../../types'
|
|
118
|
+
|
|
119
|
+
const props = withDefaults(defineProps<{
|
|
120
|
+
columns: Column[]
|
|
121
|
+
data: any[]
|
|
122
|
+
loading?: boolean
|
|
123
|
+
sortKey?: string
|
|
124
|
+
sortDir?: 'asc' | 'desc'
|
|
125
|
+
emptyMessage?: string
|
|
126
|
+
clickable?: boolean
|
|
127
|
+
}>(), {
|
|
128
|
+
loading: false,
|
|
129
|
+
emptyMessage: 'No data to display.',
|
|
130
|
+
clickable: false,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const emit = defineEmits<{
|
|
134
|
+
sort: [payload: { key: string; direction: string }]
|
|
135
|
+
'row-click': [row: any]
|
|
136
|
+
}>()
|
|
137
|
+
|
|
138
|
+
function getSortAria(col: Column): 'ascending' | 'descending' | 'none' | undefined {
|
|
139
|
+
if (!col.sortable) return undefined
|
|
140
|
+
if (props.sortKey !== col.key) return 'none'
|
|
141
|
+
return props.sortDir === 'asc' ? 'ascending' : 'descending'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function handleSort(col: Column) {
|
|
145
|
+
if (!col.sortable) return
|
|
146
|
+
|
|
147
|
+
let direction: string
|
|
148
|
+
if (props.sortKey === col.key) {
|
|
149
|
+
direction = props.sortDir === 'asc' ? 'desc' : 'asc'
|
|
150
|
+
} else {
|
|
151
|
+
direction = 'asc'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
emit('sort', { key: col.key, direction })
|
|
155
|
+
}
|
|
156
|
+
</script>
|
|
157
|
+
|
|
158
|
+
<style scoped>
|
|
159
|
+
.inv-table-wrapper {
|
|
160
|
+
overflow-x: auto;
|
|
161
|
+
-webkit-overflow-scrolling: touch;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.inv-table {
|
|
165
|
+
width: 100%;
|
|
166
|
+
border-collapse: collapse;
|
|
167
|
+
font-size: var(--text-sm, 0.875rem);
|
|
168
|
+
color: var(--admin-text-primary);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.inv-table__head {
|
|
172
|
+
position: sticky;
|
|
173
|
+
top: 0;
|
|
174
|
+
z-index: 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.inv-table__th {
|
|
178
|
+
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
|
|
179
|
+
background: var(--admin-table-header-bg);
|
|
180
|
+
font-weight: 600;
|
|
181
|
+
font-size: var(--text-xs, 0.75rem);
|
|
182
|
+
text-transform: uppercase;
|
|
183
|
+
letter-spacing: 0.05em;
|
|
184
|
+
color: var(--admin-text-secondary);
|
|
185
|
+
border-bottom: 1px solid var(--admin-border);
|
|
186
|
+
white-space: nowrap;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.inv-table__th--left { text-align: left; }
|
|
190
|
+
.inv-table__th--center { text-align: center; }
|
|
191
|
+
.inv-table__th--right { text-align: right; }
|
|
192
|
+
|
|
193
|
+
.inv-table__sort-btn {
|
|
194
|
+
display: inline-flex;
|
|
195
|
+
align-items: center;
|
|
196
|
+
gap: var(--space-1, 0.25rem);
|
|
197
|
+
padding: 0;
|
|
198
|
+
margin: 0;
|
|
199
|
+
border: none;
|
|
200
|
+
background: none;
|
|
201
|
+
font: inherit;
|
|
202
|
+
color: inherit;
|
|
203
|
+
cursor: pointer;
|
|
204
|
+
border-radius: 4px;
|
|
205
|
+
text-transform: uppercase;
|
|
206
|
+
letter-spacing: 0.05em;
|
|
207
|
+
font-weight: 600;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.inv-table__sort-btn:hover {
|
|
211
|
+
color: var(--admin-text-primary);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.inv-table__sort-btn:focus-visible {
|
|
215
|
+
outline: 2px solid var(--admin-focus-ring, #2563eb);
|
|
216
|
+
outline-offset: 2px;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.inv-table__sort-btn--active {
|
|
220
|
+
color: var(--color-primary, #2563eb);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.inv-table__sort-icon {
|
|
224
|
+
flex-shrink: 0;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.inv-table__row {
|
|
228
|
+
background: var(--admin-table-row-bg);
|
|
229
|
+
border-bottom: 1px solid var(--admin-border);
|
|
230
|
+
transition: background-color 0.1s ease;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.inv-table__row:hover {
|
|
234
|
+
background: var(--admin-table-row-hover);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.inv-table__row--clickable {
|
|
238
|
+
cursor: pointer;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.inv-table__row--clickable:focus-visible {
|
|
242
|
+
outline: 2px solid var(--admin-focus-ring, #2563eb);
|
|
243
|
+
outline-offset: -2px;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.inv-table__row--skeleton:hover {
|
|
247
|
+
background: var(--admin-table-row-bg);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.inv-table__td {
|
|
251
|
+
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
|
|
252
|
+
vertical-align: middle;
|
|
253
|
+
line-height: 1.5;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.inv-table__td--left { text-align: left; }
|
|
257
|
+
.inv-table__td--center { text-align: center; }
|
|
258
|
+
.inv-table__td--right { text-align: right; }
|
|
259
|
+
|
|
260
|
+
.inv-table__td--empty {
|
|
261
|
+
text-align: center;
|
|
262
|
+
padding: var(--space-8, 2rem) var(--space-4, 1rem);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.inv-table__empty-text {
|
|
266
|
+
color: var(--admin-text-tertiary);
|
|
267
|
+
font-style: italic;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.inv-table__skeleton-bar {
|
|
271
|
+
height: 14px;
|
|
272
|
+
width: 70%;
|
|
273
|
+
border-radius: 4px;
|
|
274
|
+
background: linear-gradient(
|
|
275
|
+
90deg,
|
|
276
|
+
var(--admin-border) 25%,
|
|
277
|
+
var(--admin-content-bg) 50%,
|
|
278
|
+
var(--admin-border) 75%
|
|
279
|
+
);
|
|
280
|
+
background-size: 200% 100%;
|
|
281
|
+
animation: inv-table-shimmer 1.5s ease-in-out infinite;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
@keyframes inv-table-shimmer {
|
|
285
|
+
0% { background-position: 200% 0; }
|
|
286
|
+
100% { background-position: -200% 0; }
|
|
287
|
+
}
|
|
288
|
+
</style>
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import { getApiClient, apiUrl } from '../api/client'
|
|
3
|
+
import type { CountSession, CountEntry, CountReport, CountStatus } from '../types'
|
|
4
|
+
|
|
5
|
+
export function useCountSessions() {
|
|
6
|
+
const sessions = ref<CountSession[]>([])
|
|
7
|
+
const currentSession = ref<CountSession | null>(null)
|
|
8
|
+
const entries = ref<CountEntry[]>([])
|
|
9
|
+
const report = ref<CountReport | null>(null)
|
|
10
|
+
const loading = ref(false)
|
|
11
|
+
const error = ref<string | null>(null)
|
|
12
|
+
|
|
13
|
+
async function fetchSessions(status?: CountStatus): Promise<void> {
|
|
14
|
+
loading.value = true
|
|
15
|
+
error.value = null
|
|
16
|
+
try {
|
|
17
|
+
const params = status ? `?status=${status}` : ''
|
|
18
|
+
const response = await getApiClient().get(apiUrl(`/counts${params}`))
|
|
19
|
+
sessions.value = response.data.data
|
|
20
|
+
} catch (err: any) {
|
|
21
|
+
error.value = err.response?.data?.message || 'Failed to load sessions'
|
|
22
|
+
throw err
|
|
23
|
+
} finally {
|
|
24
|
+
loading.value = false
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function createSession(name: string, scopeLocationId?: number): Promise<CountSession> {
|
|
29
|
+
loading.value = true
|
|
30
|
+
error.value = null
|
|
31
|
+
try {
|
|
32
|
+
const response = await getApiClient().post(apiUrl('/counts'), {
|
|
33
|
+
name,
|
|
34
|
+
scope_location_id: scopeLocationId || null,
|
|
35
|
+
})
|
|
36
|
+
const session = response.data.data
|
|
37
|
+
sessions.value.unshift(session)
|
|
38
|
+
return session
|
|
39
|
+
} catch (err: any) {
|
|
40
|
+
error.value = err.response?.data?.message || 'Failed to create session'
|
|
41
|
+
throw err
|
|
42
|
+
} finally {
|
|
43
|
+
loading.value = false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function fetchSession(id: number): Promise<CountSession> {
|
|
48
|
+
loading.value = true
|
|
49
|
+
error.value = null
|
|
50
|
+
try {
|
|
51
|
+
const response = await getApiClient().get(apiUrl(`/counts/${id}`))
|
|
52
|
+
currentSession.value = response.data.data
|
|
53
|
+
return response.data.data
|
|
54
|
+
} catch (err: any) {
|
|
55
|
+
error.value = err.response?.data?.message || 'Failed to load session'
|
|
56
|
+
throw err
|
|
57
|
+
} finally {
|
|
58
|
+
loading.value = false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function updateStatus(id: number, status: 'in_progress' | 'cancelled'): Promise<void> {
|
|
63
|
+
loading.value = true
|
|
64
|
+
error.value = null
|
|
65
|
+
try {
|
|
66
|
+
const response = await getApiClient().patch(apiUrl(`/counts/${id}`), { status })
|
|
67
|
+
currentSession.value = response.data.data
|
|
68
|
+
} catch (err: any) {
|
|
69
|
+
error.value = err.response?.data?.message || 'Failed to update session'
|
|
70
|
+
throw err
|
|
71
|
+
} finally {
|
|
72
|
+
loading.value = false
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function generateEntries(id: number): Promise<{ entries_created: number }> {
|
|
77
|
+
loading.value = true
|
|
78
|
+
error.value = null
|
|
79
|
+
try {
|
|
80
|
+
const response = await getApiClient().post(apiUrl(`/counts/${id}/generate`))
|
|
81
|
+
return response.data
|
|
82
|
+
} catch (err: any) {
|
|
83
|
+
error.value = err.response?.data?.message || 'Failed to generate entries'
|
|
84
|
+
throw err
|
|
85
|
+
} finally {
|
|
86
|
+
loading.value = false
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function fetchEntries(
|
|
91
|
+
sessionId: number,
|
|
92
|
+
filters?: { status?: string; location_id?: number; discrepancies_only?: boolean }
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
loading.value = true
|
|
95
|
+
error.value = null
|
|
96
|
+
try {
|
|
97
|
+
const params = new URLSearchParams()
|
|
98
|
+
if (filters?.status) params.set('status', filters.status)
|
|
99
|
+
if (filters?.location_id) params.set('location_id', String(filters.location_id))
|
|
100
|
+
if (filters?.discrepancies_only) params.set('discrepancies_only', '1')
|
|
101
|
+
const response = await getApiClient().get(apiUrl(`/counts/${sessionId}/entries?${params}`))
|
|
102
|
+
entries.value = response.data.data
|
|
103
|
+
} catch (err: any) {
|
|
104
|
+
error.value = err.response?.data?.message || 'Failed to load entries'
|
|
105
|
+
throw err
|
|
106
|
+
} finally {
|
|
107
|
+
loading.value = false
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function submitCount(
|
|
112
|
+
sessionId: number,
|
|
113
|
+
entryId: number,
|
|
114
|
+
countedQuantity: number,
|
|
115
|
+
notes?: string
|
|
116
|
+
): Promise<CountEntry> {
|
|
117
|
+
error.value = null
|
|
118
|
+
try {
|
|
119
|
+
const response = await getApiClient().post(
|
|
120
|
+
apiUrl(`/counts/${sessionId}/entries/${entryId}/count`),
|
|
121
|
+
{ counted_quantity: countedQuantity, notes }
|
|
122
|
+
)
|
|
123
|
+
const updated = response.data.data
|
|
124
|
+
const idx = entries.value.findIndex(e => e.id === entryId)
|
|
125
|
+
if (idx !== -1) entries.value[idx] = updated
|
|
126
|
+
return updated
|
|
127
|
+
} catch (err: any) {
|
|
128
|
+
error.value = err.response?.data?.message || 'Failed to submit count'
|
|
129
|
+
throw err
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function verifyEntry(sessionId: number, entryId: number): Promise<CountEntry> {
|
|
134
|
+
error.value = null
|
|
135
|
+
try {
|
|
136
|
+
const response = await getApiClient().post(
|
|
137
|
+
apiUrl(`/counts/${sessionId}/entries/${entryId}/verify`)
|
|
138
|
+
)
|
|
139
|
+
const updated = response.data.data
|
|
140
|
+
const idx = entries.value.findIndex(e => e.id === entryId)
|
|
141
|
+
if (idx !== -1) entries.value[idx] = updated
|
|
142
|
+
return updated
|
|
143
|
+
} catch (err: any) {
|
|
144
|
+
error.value = err.response?.data?.message || 'Failed to verify entry'
|
|
145
|
+
throw err
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function applySession(id: number): Promise<{ adjustments_made: number; total_discrepancy: number }> {
|
|
150
|
+
loading.value = true
|
|
151
|
+
error.value = null
|
|
152
|
+
try {
|
|
153
|
+
const response = await getApiClient().post(apiUrl(`/counts/${id}/apply`))
|
|
154
|
+
return response.data
|
|
155
|
+
} catch (err: any) {
|
|
156
|
+
error.value = err.response?.data?.message || 'Failed to apply session'
|
|
157
|
+
throw err
|
|
158
|
+
} finally {
|
|
159
|
+
loading.value = false
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function fetchReport(id: number): Promise<CountReport> {
|
|
164
|
+
loading.value = true
|
|
165
|
+
error.value = null
|
|
166
|
+
try {
|
|
167
|
+
const response = await getApiClient().get(apiUrl(`/counts/${id}/report`))
|
|
168
|
+
report.value = response.data
|
|
169
|
+
return response.data
|
|
170
|
+
} catch (err: any) {
|
|
171
|
+
error.value = err.response?.data?.message || 'Failed to load report'
|
|
172
|
+
throw err
|
|
173
|
+
} finally {
|
|
174
|
+
loading.value = false
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
sessions, currentSession, entries, report, loading, error,
|
|
180
|
+
fetchSessions, createSession, fetchSession, updateStatus,
|
|
181
|
+
generateEntries, fetchEntries, submitCount, verifyEntry,
|
|
182
|
+
applySession, fetchReport,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import { getApiClient, apiUrl } from '../api/client'
|
|
3
|
+
import type { LabelData, LabelFormat } from '../types'
|
|
4
|
+
|
|
5
|
+
export function useLabelPrinting() {
|
|
6
|
+
const labelData = ref<LabelData | null>(null)
|
|
7
|
+
const loading = ref(false)
|
|
8
|
+
const error = ref<string | null>(null)
|
|
9
|
+
|
|
10
|
+
async function fetchLabel(locationId: number, format: LabelFormat = 'standard'): Promise<LabelData> {
|
|
11
|
+
loading.value = true
|
|
12
|
+
error.value = null
|
|
13
|
+
try {
|
|
14
|
+
const response = await getApiClient().get(apiUrl(`/locations/${locationId}/label?format=${format}`))
|
|
15
|
+
labelData.value = response.data
|
|
16
|
+
return response.data
|
|
17
|
+
} catch (err: any) {
|
|
18
|
+
error.value = err.response?.data?.message || 'Failed to load label'
|
|
19
|
+
throw err
|
|
20
|
+
} finally {
|
|
21
|
+
loading.value = false
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function printLabel(locationId: number, format: LabelFormat = 'standard'): Promise<void> {
|
|
26
|
+
loading.value = true
|
|
27
|
+
error.value = null
|
|
28
|
+
try {
|
|
29
|
+
const response = await getApiClient().post(
|
|
30
|
+
apiUrl(`/locations/${locationId}/label/print?format=${format}`)
|
|
31
|
+
)
|
|
32
|
+
openPrintWindow(response.data)
|
|
33
|
+
} catch (err: any) {
|
|
34
|
+
error.value = err.response?.data?.message || 'Failed to print label'
|
|
35
|
+
throw err
|
|
36
|
+
} finally {
|
|
37
|
+
loading.value = false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function printBatch(locationIds: number[], format: LabelFormat = 'standard'): Promise<void> {
|
|
42
|
+
loading.value = true
|
|
43
|
+
error.value = null
|
|
44
|
+
try {
|
|
45
|
+
const response = await getApiClient().post(apiUrl('/labels/batch'), {
|
|
46
|
+
location_ids: locationIds,
|
|
47
|
+
format,
|
|
48
|
+
})
|
|
49
|
+
openPrintWindow(response.data)
|
|
50
|
+
} catch (err: any) {
|
|
51
|
+
error.value = err.response?.data?.message || 'Failed to print batch'
|
|
52
|
+
throw err
|
|
53
|
+
} finally {
|
|
54
|
+
loading.value = false
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function openPrintWindow(html: string): void {
|
|
59
|
+
const win = window.open('', '_blank')
|
|
60
|
+
if (win) {
|
|
61
|
+
win.document.write(html)
|
|
62
|
+
win.document.close()
|
|
63
|
+
win.onload = () => {
|
|
64
|
+
win.print()
|
|
65
|
+
setTimeout(() => win.close(), 1000)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { labelData, loading, error, fetchLabel, printLabel, printBatch }
|
|
71
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ref, inject } from 'vue'
|
|
2
|
+
import type { PathSegment } from '../types'
|
|
3
|
+
|
|
4
|
+
export function useLocationBreadcrumbs() {
|
|
5
|
+
const breadcrumbs = ref<PathSegment[]>([])
|
|
6
|
+
|
|
7
|
+
function setBreadcrumbs(path: PathSegment[]) {
|
|
8
|
+
breadcrumbs.value = path
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function navigateTo(segment: PathSegment) {
|
|
12
|
+
const router = inject<any>('router', null)
|
|
13
|
+
if (router) {
|
|
14
|
+
router.push({ name: 'inventory-location', params: { code: segment.full_code } })
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return { breadcrumbs, setBreadcrumbs, navigateTo }
|
|
19
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import { getApiClient, apiUrl } from '../api/client'
|
|
3
|
+
import type { LocationProduct, PlaceProductData, MoveProductData } from '../types'
|
|
4
|
+
|
|
5
|
+
export function useLocationProducts() {
|
|
6
|
+
const products = ref<LocationProduct[]>([])
|
|
7
|
+
const loading = ref(false)
|
|
8
|
+
const error = ref<string | null>(null)
|
|
9
|
+
|
|
10
|
+
async function fetchProducts(locationId: number): Promise<void> {
|
|
11
|
+
loading.value = true
|
|
12
|
+
error.value = null
|
|
13
|
+
try {
|
|
14
|
+
const response = await getApiClient().get(apiUrl(`/locations/${locationId}/products`))
|
|
15
|
+
products.value = response.data.data
|
|
16
|
+
} catch (err: any) {
|
|
17
|
+
error.value = err.response?.data?.message || 'Failed to load products'
|
|
18
|
+
throw err
|
|
19
|
+
} finally {
|
|
20
|
+
loading.value = false
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function addProduct(locationId: number, data: PlaceProductData): Promise<LocationProduct> {
|
|
25
|
+
loading.value = true
|
|
26
|
+
error.value = null
|
|
27
|
+
try {
|
|
28
|
+
const response = await getApiClient().post(apiUrl(`/locations/${locationId}/products`), data)
|
|
29
|
+
const product = response.data.data
|
|
30
|
+
await fetchProducts(locationId)
|
|
31
|
+
return product
|
|
32
|
+
} catch (err: any) {
|
|
33
|
+
error.value = err.response?.data?.message || 'Failed to add product'
|
|
34
|
+
throw err
|
|
35
|
+
} finally {
|
|
36
|
+
loading.value = false
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function updateProduct(
|
|
41
|
+
locationId: number,
|
|
42
|
+
productId: number | string,
|
|
43
|
+
data: { quantity?: number; is_primary?: boolean; notes?: string }
|
|
44
|
+
): Promise<LocationProduct> {
|
|
45
|
+
loading.value = true
|
|
46
|
+
error.value = null
|
|
47
|
+
try {
|
|
48
|
+
const response = await getApiClient().patch(
|
|
49
|
+
apiUrl(`/locations/${locationId}/products/${productId}`),
|
|
50
|
+
data
|
|
51
|
+
)
|
|
52
|
+
const updated = response.data.data
|
|
53
|
+
const idx = products.value.findIndex(p => p.product_id === productId)
|
|
54
|
+
if (idx !== -1) products.value[idx] = updated
|
|
55
|
+
return updated
|
|
56
|
+
} catch (err: any) {
|
|
57
|
+
error.value = err.response?.data?.message || 'Failed to update product'
|
|
58
|
+
throw err
|
|
59
|
+
} finally {
|
|
60
|
+
loading.value = false
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function removeProduct(locationId: number, productId: number | string, quantity?: number): Promise<void> {
|
|
65
|
+
loading.value = true
|
|
66
|
+
error.value = null
|
|
67
|
+
try {
|
|
68
|
+
const params = quantity !== undefined ? `?quantity=${quantity}` : ''
|
|
69
|
+
await getApiClient().delete(apiUrl(`/locations/${locationId}/products/${productId}${params}`))
|
|
70
|
+
products.value = products.value.filter(p => p.product_id !== productId)
|
|
71
|
+
} catch (err: any) {
|
|
72
|
+
error.value = err.response?.data?.message || 'Failed to remove product'
|
|
73
|
+
throw err
|
|
74
|
+
} finally {
|
|
75
|
+
loading.value = false
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function moveProduct(productId: number | string, data: MoveProductData): Promise<void> {
|
|
80
|
+
loading.value = true
|
|
81
|
+
error.value = null
|
|
82
|
+
try {
|
|
83
|
+
await getApiClient().post(apiUrl(`/products/${productId}/move`), data)
|
|
84
|
+
} catch (err: any) {
|
|
85
|
+
error.value = err.response?.data?.message || 'Failed to move product'
|
|
86
|
+
throw err
|
|
87
|
+
} finally {
|
|
88
|
+
loading.value = false
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function bulkAdd(locationId: number, items: PlaceProductData[]): Promise<void> {
|
|
93
|
+
loading.value = true
|
|
94
|
+
error.value = null
|
|
95
|
+
try {
|
|
96
|
+
await getApiClient().post(apiUrl(`/locations/${locationId}/products/bulk`), { items })
|
|
97
|
+
await fetchProducts(locationId)
|
|
98
|
+
} catch (err: any) {
|
|
99
|
+
error.value = err.response?.data?.message || 'Failed to bulk add products'
|
|
100
|
+
throw err
|
|
101
|
+
} finally {
|
|
102
|
+
loading.value = false
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function fetchProductLocations(productId: number | string): Promise<LocationProduct[]> {
|
|
107
|
+
loading.value = true
|
|
108
|
+
error.value = null
|
|
109
|
+
try {
|
|
110
|
+
const response = await getApiClient().get(apiUrl(`/products/${productId}/locations`))
|
|
111
|
+
return response.data.data
|
|
112
|
+
} catch (err: any) {
|
|
113
|
+
error.value = err.response?.data?.message || 'Failed to load product locations'
|
|
114
|
+
throw err
|
|
115
|
+
} finally {
|
|
116
|
+
loading.value = false
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
products, loading, error,
|
|
122
|
+
fetchProducts, addProduct, updateProduct, removeProduct,
|
|
123
|
+
moveProduct, bulkAdd, fetchProductLocations,
|
|
124
|
+
}
|
|
125
|
+
}
|