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