@fayz-ai/plugin-inventory 0.1.1

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 (93) hide show
  1. package/dist/InventoryContext.d.ts +37 -0
  2. package/dist/InventoryContext.d.ts.map +1 -0
  3. package/dist/InventoryPage.d.ts +13 -0
  4. package/dist/InventoryPage.d.ts.map +1 -0
  5. package/dist/components/InventoryGeneralSettings.d.ts +3 -0
  6. package/dist/components/InventoryGeneralSettings.d.ts.map +1 -0
  7. package/dist/components/InventoryOnboarding.d.ts +5 -0
  8. package/dist/components/InventoryOnboarding.d.ts.map +1 -0
  9. package/dist/components/InventorySettings.d.ts +8 -0
  10. package/dist/components/InventorySettings.d.ts.map +1 -0
  11. package/dist/data/index.d.ts +3 -0
  12. package/dist/data/index.d.ts.map +1 -0
  13. package/dist/data/mock.d.ts +3 -0
  14. package/dist/data/mock.d.ts.map +1 -0
  15. package/dist/data/supabase.d.ts +3 -0
  16. package/dist/data/supabase.d.ts.map +1 -0
  17. package/dist/data/types.d.ts +22 -0
  18. package/dist/data/types.d.ts.map +1 -0
  19. package/dist/index.cjs +2936 -0
  20. package/dist/index.cjs.map +1 -0
  21. package/dist/index.d.ts +48 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +2930 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/lib/tenant.d.ts +3 -0
  26. package/dist/lib/tenant.d.ts.map +1 -0
  27. package/dist/locales/en.d.ts +2 -0
  28. package/dist/locales/en.d.ts.map +1 -0
  29. package/dist/locales/index.d.ts +2 -0
  30. package/dist/locales/index.d.ts.map +1 -0
  31. package/dist/locales/pt-BR.d.ts +2 -0
  32. package/dist/locales/pt-BR.d.ts.map +1 -0
  33. package/dist/registries.d.ts +3 -0
  34. package/dist/registries.d.ts.map +1 -0
  35. package/dist/store.d.ts +28 -0
  36. package/dist/store.d.ts.map +1 -0
  37. package/dist/types.d.ts +213 -0
  38. package/dist/types.d.ts.map +1 -0
  39. package/dist/views/DashboardView.d.ts +8 -0
  40. package/dist/views/DashboardView.d.ts.map +1 -0
  41. package/dist/views/MovementHistoryView.d.ts +5 -0
  42. package/dist/views/MovementHistoryView.d.ts.map +1 -0
  43. package/dist/views/ProductCrudForm.d.ts +6 -0
  44. package/dist/views/ProductCrudForm.d.ts.map +1 -0
  45. package/dist/views/ProductFormView.d.ts +6 -0
  46. package/dist/views/ProductFormView.d.ts.map +1 -0
  47. package/dist/views/ProductListView.d.ts +6 -0
  48. package/dist/views/ProductListView.d.ts.map +1 -0
  49. package/dist/views/RecipeDetailView.d.ts +6 -0
  50. package/dist/views/RecipeDetailView.d.ts.map +1 -0
  51. package/dist/views/RecipeFormView.d.ts +5 -0
  52. package/dist/views/RecipeFormView.d.ts.map +1 -0
  53. package/dist/views/RecipesView.d.ts +6 -0
  54. package/dist/views/RecipesView.d.ts.map +1 -0
  55. package/dist/views/StockMovementView.d.ts +8 -0
  56. package/dist/views/StockMovementView.d.ts.map +1 -0
  57. package/dist/views/dashboardWidgets.d.ts +11 -0
  58. package/dist/views/dashboardWidgets.d.ts.map +1 -0
  59. package/dist/views/productEntity.d.ts +6 -0
  60. package/dist/views/productEntity.d.ts.map +1 -0
  61. package/package.json +55 -0
  62. package/src/InventoryContext.tsx +40 -0
  63. package/src/InventoryPage.tsx +170 -0
  64. package/src/README.md +177 -0
  65. package/src/components/InventoryGeneralSettings.tsx +26 -0
  66. package/src/components/InventoryOnboarding.tsx +60 -0
  67. package/src/components/InventorySettings.tsx +27 -0
  68. package/src/data/index.ts +2 -0
  69. package/src/data/mock.ts +266 -0
  70. package/src/data/supabase.ts +358 -0
  71. package/src/data/types.ts +35 -0
  72. package/src/index.ts +191 -0
  73. package/src/lib/tenant.ts +4 -0
  74. package/src/locales/en.ts +242 -0
  75. package/src/locales/index.ts +7 -0
  76. package/src/locales/pt-BR.ts +242 -0
  77. package/src/migrations/001_inventory_base.sql +69 -0
  78. package/src/migrations/002_recipes.sql +34 -0
  79. package/src/migrations/003_measurement_units.sql +13 -0
  80. package/src/registries.ts +111 -0
  81. package/src/store.ts +127 -0
  82. package/src/types.ts +256 -0
  83. package/src/views/DashboardView.tsx +11 -0
  84. package/src/views/MovementHistoryView.tsx +104 -0
  85. package/src/views/ProductCrudForm.tsx +99 -0
  86. package/src/views/ProductFormView.tsx +283 -0
  87. package/src/views/ProductListView.tsx +107 -0
  88. package/src/views/RecipeDetailView.tsx +192 -0
  89. package/src/views/RecipeFormView.tsx +235 -0
  90. package/src/views/RecipesView.tsx +103 -0
  91. package/src/views/StockMovementView.tsx +516 -0
  92. package/src/views/dashboardWidgets.tsx +101 -0
  93. package/src/views/productEntity.tsx +124 -0
@@ -0,0 +1,266 @@
1
+ import type { InventoryDataProvider } from './types'
2
+ import type {
3
+ Product, StockMovement, StockPosition, StockLocation,
4
+ Recipe, RecipeIngredient,
5
+ CreateProductInput, CreateStockMovementInput, CreateRecipeInput,
6
+ ProductQuery, MovementQuery,
7
+ PaginatedResult, InventorySummary, MovementType,
8
+ } from '../types'
9
+
10
+ let nextId = 1
11
+ function uid(): string { return String(nextId++) }
12
+ function now(): string { return new Date().toISOString() }
13
+ function today(): string { return new Date().toISOString().slice(0, 10) }
14
+
15
+ function paginate<T>(items: T[], page?: number, pageSize?: number): PaginatedResult<T> {
16
+ const p = page ?? 1
17
+ const ps = pageSize ?? 50
18
+ const start = (p - 1) * ps
19
+ return { data: items.slice(start, start + ps), total: items.length }
20
+ }
21
+
22
+ interface MockStore {
23
+ products: Product[]
24
+ movements: StockMovement[]
25
+ positions: StockPosition[]
26
+ locations: StockLocation[]
27
+ recipes: Recipe[]
28
+ recipeIngredients: RecipeIngredient[]
29
+ }
30
+
31
+ function createStore(): MockStore {
32
+ return {
33
+ products: [],
34
+ movements: [],
35
+ positions: [],
36
+ locations: [
37
+ { id: uid(), name: 'Main Storage', description: 'Primary storage area', isActive: true, tenantId: 'mock-tenant', createdAt: now(), updatedAt: now() },
38
+ ],
39
+ recipes: [],
40
+ recipeIngredients: [],
41
+ }
42
+ }
43
+
44
+ export function createMockInventoryProvider(): InventoryDataProvider {
45
+ const store = createStore()
46
+ const tenantId = 'mock-tenant'
47
+
48
+ const provider: InventoryDataProvider = {
49
+ // --- Products ---
50
+ async getProducts(query: ProductQuery): Promise<PaginatedResult<Product>> {
51
+ let results = [...store.products]
52
+ if (query.productType) results = results.filter((p) => p.productType === query.productType)
53
+ if (query.categoryId) results = results.filter((p) => p.categoryId === query.categoryId)
54
+ if (query.isActive !== undefined) results = results.filter((p) => p.isActive === query.isActive)
55
+ if (query.lowStockOnly) results = results.filter((p) => p.currentQuantity <= p.minQuantity)
56
+ if (query.search) {
57
+ const s = query.search.toLowerCase()
58
+ results = results.filter((p) => p.name.toLowerCase().includes(s) || p.sku?.toLowerCase().includes(s) || p.barcode?.includes(s))
59
+ }
60
+ results.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
61
+ return paginate(results, query.page, query.pageSize)
62
+ },
63
+
64
+ async getProductById(id: string): Promise<Product | null> {
65
+ return store.products.find((p) => p.id === id) ?? null
66
+ },
67
+
68
+ async createProduct(input: CreateProductInput): Promise<Product> {
69
+ const product: Product = {
70
+ id: uid(),
71
+ name: input.name,
72
+ description: input.description,
73
+ sku: input.sku,
74
+ barcode: input.barcode,
75
+ brand: input.brand,
76
+ categoryId: input.categoryId,
77
+ productType: input.productType,
78
+ purpose: input.purpose,
79
+ currentQuantity: 0,
80
+ minQuantity: input.minQuantity ?? 0,
81
+ maxQuantity: input.maxQuantity,
82
+ costPrice: input.costPrice ?? 0,
83
+ salePrice: input.salePrice,
84
+ measurementUnitId: input.measurementUnitId,
85
+ isActive: true,
86
+ supplierId: input.supplierId,
87
+ defaultLocationId: input.defaultLocationId,
88
+ imageUrl: input.imageUrl,
89
+ metadata: input.metadata,
90
+ tenantId,
91
+ createdAt: now(),
92
+ updatedAt: now(),
93
+ }
94
+ store.products.push(product)
95
+ return product
96
+ },
97
+
98
+ async updateProduct(id: string, data: Partial<Product>): Promise<Product> {
99
+ const product = store.products.find((p) => p.id === id)
100
+ if (!product) throw new Error(`Product ${id} not found`)
101
+ Object.assign(product, data, { updatedAt: now() })
102
+ return product
103
+ },
104
+
105
+ // --- Stock Movements ---
106
+ async getMovements(query: MovementQuery): Promise<PaginatedResult<StockMovement>> {
107
+ let results = [...store.movements]
108
+ if (query.productId) results = results.filter((m) => m.productId === query.productId)
109
+ if (query.movementType) {
110
+ const types = Array.isArray(query.movementType) ? query.movementType : [query.movementType]
111
+ results = results.filter((m) => types.includes(m.movementType))
112
+ }
113
+ if (query.stockLocationId) results = results.filter((m) => m.stockLocationId === query.stockLocationId)
114
+ if (query.dateRange) {
115
+ results = results.filter((m) => m.movementDate >= query.dateRange!.from && m.movementDate <= query.dateRange!.to)
116
+ }
117
+ if (query.search) {
118
+ const s = query.search.toLowerCase()
119
+ results = results.filter((m) => m.productName?.toLowerCase().includes(s) || m.notes?.toLowerCase().includes(s))
120
+ }
121
+ results.sort((a, b) => b.movementDate.localeCompare(a.movementDate))
122
+ return paginate(results, query.page, query.pageSize)
123
+ },
124
+
125
+ async createMovement(input: CreateStockMovementInput): Promise<StockMovement> {
126
+ const product = store.products.find((p) => p.id === input.productId)
127
+ const location = input.stockLocationId ? store.locations.find((l) => l.id === input.stockLocationId) : undefined
128
+ const unitCost = input.unitCost ?? product?.costPrice ?? 0
129
+
130
+ const movement: StockMovement = {
131
+ id: uid(),
132
+ productId: input.productId,
133
+ productName: product?.name,
134
+ quantity: input.quantity,
135
+ movementType: input.movementType,
136
+ unitCost,
137
+ totalCost: unitCost * input.quantity,
138
+ stockLocationId: input.stockLocationId,
139
+ stockLocationName: location?.name,
140
+ destinationLocationId: input.destinationLocationId,
141
+ supplierId: input.supplierId,
142
+ documentNumber: input.documentNumber,
143
+ reason: input.reason,
144
+ notes: input.notes,
145
+ movementDate: input.movementDate ?? today(),
146
+ tenantId,
147
+ createdAt: now(),
148
+ }
149
+ store.movements.push(movement)
150
+
151
+ // Update product quantity
152
+ if (product) {
153
+ if (input.movementType === 'entry') product.currentQuantity += input.quantity
154
+ else if (input.movementType === 'exit' || input.movementType === 'loss') product.currentQuantity -= input.quantity
155
+ // adjustment sets absolute, transfer moves between locations
156
+ product.updatedAt = now()
157
+ }
158
+
159
+ return movement
160
+ },
161
+
162
+ // --- Stock Positions ---
163
+ async getPositions(productId: string): Promise<StockPosition[]> {
164
+ return store.positions.filter((p) => p.productId === productId)
165
+ },
166
+
167
+ // --- Stock Locations ---
168
+ async getLocations(): Promise<StockLocation[]> {
169
+ return store.locations.filter((l) => l.isActive)
170
+ },
171
+
172
+ async createLocation(data): Promise<StockLocation> {
173
+ const location: StockLocation = {
174
+ id: uid(),
175
+ name: data.name,
176
+ description: data.description,
177
+ isActive: true,
178
+ unitId: data.unitId,
179
+ tenantId,
180
+ createdAt: now(),
181
+ updatedAt: now(),
182
+ }
183
+ store.locations.push(location)
184
+ return location
185
+ },
186
+
187
+ // --- Recipes ---
188
+ async getRecipes(): Promise<Recipe[]> {
189
+ return store.recipes.filter((r) => r.isActive)
190
+ },
191
+
192
+ async getRecipeById(id: string): Promise<Recipe | null> {
193
+ return store.recipes.find((r) => r.id === id) ?? null
194
+ },
195
+
196
+ async getRecipeIngredients(recipeId: string): Promise<RecipeIngredient[]> {
197
+ return store.recipeIngredients.filter((ri) => ri.recipeId === recipeId).sort((a, b) => a.displayOrder - b.displayOrder)
198
+ },
199
+
200
+ async createRecipe(input: CreateRecipeInput): Promise<Recipe> {
201
+ const product = store.products.find((p) => p.id === input.productId)
202
+ const recipeId = uid()
203
+ const recipe: Recipe = {
204
+ id: recipeId,
205
+ name: input.name,
206
+ description: input.description,
207
+ productId: input.productId,
208
+ productName: product?.name,
209
+ yieldQuantity: input.yieldQuantity,
210
+ yieldUnitId: input.yieldUnitId,
211
+ preparationTimeMinutes: input.preparationTimeMinutes,
212
+ instructions: input.instructions,
213
+ isActive: true,
214
+ ingredientCount: input.ingredients.length,
215
+ tenantId,
216
+ createdAt: now(),
217
+ updatedAt: now(),
218
+ }
219
+ store.recipes.push(recipe)
220
+
221
+ for (const ing of input.ingredients) {
222
+ const ingProduct = store.products.find((p) => p.id === ing.productId)
223
+ store.recipeIngredients.push({
224
+ id: uid(),
225
+ recipeId,
226
+ productId: ing.productId,
227
+ productName: ingProduct?.name,
228
+ quantity: ing.quantity,
229
+ unitId: ing.unitId,
230
+ displayOrder: ing.displayOrder ?? 0,
231
+ notes: ing.notes,
232
+ createdAt: now(),
233
+ })
234
+ }
235
+
236
+ return recipe
237
+ },
238
+
239
+ // --- Summary ---
240
+ async getSummary(): Promise<InventorySummary> {
241
+ const active = store.products.filter((p) => p.isActive)
242
+ const lowStock = active.filter((p) => p.currentQuantity <= p.minQuantity && p.currentQuantity > 0)
243
+ const outOfStock = active.filter((p) => p.currentQuantity <= 0)
244
+ const totalValue = active.reduce((sum, p) => sum + p.currentQuantity * p.costPrice, 0)
245
+
246
+ const weekAgo = new Date()
247
+ weekAgo.setDate(weekAgo.getDate() - 7)
248
+ const weekAgoStr = weekAgo.toISOString().slice(0, 10)
249
+ const recent = store.movements.filter((m) => m.movementDate >= weekAgoStr)
250
+
251
+ const movementsByType: Record<MovementType, number> = { entry: 0, exit: 0, adjustment: 0, transfer: 0, loss: 0 }
252
+ for (const m of recent) movementsByType[m.movementType]++
253
+
254
+ return {
255
+ totalProducts: active.length,
256
+ lowStockCount: lowStock.length,
257
+ outOfStockCount: outOfStock.length,
258
+ totalStockValue: totalValue,
259
+ recentMovementCount: recent.length,
260
+ movementsByType,
261
+ }
262
+ },
263
+ }
264
+
265
+ return provider
266
+ }
@@ -0,0 +1,358 @@
1
+ import type { InventoryDataProvider } from './types'
2
+ import type {
3
+ Product, StockMovement, StockPosition, StockLocation,
4
+ Recipe, RecipeIngredient,
5
+ CreateProductInput, CreateStockMovementInput, CreateRecipeInput,
6
+ ProductQuery, MovementQuery,
7
+ PaginatedResult, InventorySummary, MovementType,
8
+ } from '../types'
9
+ import { getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core'
10
+ import { getInventoryTenantId } from '../lib/tenant'
11
+
12
+ function getTenantId(): string | undefined {
13
+ // Local override wins; else use the app's active tenant so writes pass RLS.
14
+ return getInventoryTenantId() ?? getActiveTenantId()
15
+ }
16
+
17
+ function snakeToCamel(obj: Record<string, unknown>): Record<string, unknown> {
18
+ const result: Record<string, unknown> = {}
19
+ for (const [key, value] of Object.entries(obj)) {
20
+ const camelKey = key.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
21
+ result[camelKey] = value
22
+ }
23
+ return result
24
+ }
25
+
26
+ function camelToSnake(obj: Record<string, unknown>): Record<string, unknown> {
27
+ const result: Record<string, unknown> = {}
28
+ for (const [key, value] of Object.entries(obj)) {
29
+ if (key.startsWith('_')) continue
30
+ const snakeKey = key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`)
31
+ result[snakeKey] = value
32
+ }
33
+ return result
34
+ }
35
+
36
+ /** Map a saas_core.products row to our Product type */
37
+ function mapProductRow(row: Record<string, any>): Product {
38
+ const meta = row.metadata ?? {}
39
+ return {
40
+ id: row.id,
41
+ name: row.name,
42
+ description: row.description,
43
+ sku: row.sku,
44
+ barcode: meta.barcode,
45
+ brand: meta.brand,
46
+ productType: meta.productType ?? 'sale',
47
+ currentQuantity: row.stock ?? 0,
48
+ minQuantity: row.min_stock ?? 0,
49
+ maxQuantity: meta.maxQuantity,
50
+ costPrice: row.cost ?? 0,
51
+ salePrice: row.price,
52
+ isActive: row.is_active ?? true,
53
+ imageUrl: row.image_url,
54
+ metadata: meta,
55
+ tenantId: row.tenant_id,
56
+ createdAt: row.created_at,
57
+ updatedAt: row.updated_at,
58
+ }
59
+ }
60
+
61
+ export function createSupabaseInventoryProvider(): InventoryDataProvider {
62
+ // Lazy resolution — Supabase client may not exist at factory time
63
+ // but will be available when methods are called (after createSaasApp initializes it)
64
+ function getClients() {
65
+ const supabase = getSupabaseClientOptional() as any
66
+ if (!supabase) throw new Error('Supabase not initialized')
67
+ return { core: supabase.schema('saas_core'), pub: supabase }
68
+ }
69
+
70
+ const provider: InventoryDataProvider = {
71
+ // --- Products (saas_core.products) ---
72
+ async getProducts(query: ProductQuery): Promise<PaginatedResult<Product>> {
73
+ const { core, pub } = getClients()
74
+ let qb = core.from('products').select('*', { count: 'exact' })
75
+ if (query.search) qb = qb.ilike('name', `%${query.search}%`)
76
+ if (query.isActive !== undefined) qb = qb.eq('is_active', query.isActive)
77
+ if (query.lowStockOnly) qb = qb.lte('stock', 0) // simplified
78
+ const page = query.page ?? 1
79
+ const pageSize = query.pageSize ?? 50
80
+ qb = qb.range((page - 1) * pageSize, page * pageSize - 1).order('created_at', { ascending: false })
81
+ const { data, count } = await qb
82
+ return { data: (data ?? []).map(mapProductRow), total: count ?? 0 }
83
+ },
84
+
85
+ async getProductById(id: string): Promise<Product | null> {
86
+ const { core } = getClients()
87
+ const { data } = await core.from('products').select('*').eq('id', id).single()
88
+ return data ? mapProductRow(data) : null
89
+ },
90
+
91
+ async createProduct(input: CreateProductInput): Promise<Product> {
92
+ const { core } = getClients()
93
+ const tenantId = getTenantId()
94
+ const row: Record<string, unknown> = {
95
+ tenant_id: tenantId,
96
+ name: input.name,
97
+ description: input.description,
98
+ sku: input.sku,
99
+ price: input.salePrice ?? 0,
100
+ cost: input.costPrice ?? 0,
101
+ stock: 0,
102
+ min_stock: input.minQuantity ?? 0,
103
+ is_active: true,
104
+ image_url: input.imageUrl,
105
+ metadata: { productType: input.productType, barcode: input.barcode, brand: input.brand, maxQuantity: input.maxQuantity },
106
+ }
107
+ const { data, error } = await core.from('products').insert(row).select().single()
108
+ if (error) throw new Error(error.message)
109
+ return mapProductRow(data!)
110
+ },
111
+
112
+ async updateProduct(id: string, partial: Partial<Product>): Promise<Product> {
113
+ const { core } = getClients()
114
+ const row: Record<string, unknown> = {}
115
+ if (partial.name !== undefined) row.name = partial.name
116
+ if (partial.description !== undefined) row.description = partial.description
117
+ if (partial.sku !== undefined) row.sku = partial.sku
118
+ if (partial.salePrice !== undefined) row.price = partial.salePrice
119
+ if (partial.costPrice !== undefined) row.cost = partial.costPrice
120
+ if (partial.minQuantity !== undefined) row.min_stock = partial.minQuantity
121
+ if (partial.isActive !== undefined) row.is_active = partial.isActive
122
+ if (partial.imageUrl !== undefined) row.image_url = partial.imageUrl
123
+ if (partial.productType !== undefined || partial.barcode !== undefined || partial.brand !== undefined) {
124
+ row.metadata = { productType: partial.productType, barcode: partial.barcode, brand: partial.brand }
125
+ }
126
+ const { data, error } = await core.from('products').update(row).eq('id', id).select().single()
127
+ if (error) throw new Error(error.message)
128
+ return mapProductRow(data!)
129
+ },
130
+
131
+ // --- Stock Movements (via view with product join — single query) ---
132
+ async getMovements(query: MovementQuery): Promise<PaginatedResult<StockMovement>> {
133
+ const { pub } = getClients()
134
+ // Single query via v_stock_movements view (JOINs with saas_core.products)
135
+ let qb = pub.from('v_stock_movements').select('*', { count: 'exact' })
136
+ if (query.productId) qb = qb.eq('product_id', query.productId)
137
+ if (query.movementType) {
138
+ const types = Array.isArray(query.movementType) ? query.movementType : [query.movementType]
139
+ qb = qb.in('movement_type', types)
140
+ }
141
+ if (query.stockLocationId) qb = qb.eq('stock_location_id', query.stockLocationId)
142
+ if (query.dateRange) qb = qb.gte('movement_date', query.dateRange.from).lte('movement_date', query.dateRange.to)
143
+ if (query.search) qb = qb.ilike('product_name', `%${query.search}%`)
144
+ const page = query.page ?? 1
145
+ const pageSize = query.pageSize ?? 50
146
+ qb = qb.range((page - 1) * pageSize, page * pageSize - 1).order('movement_date', { ascending: false })
147
+ const { data, count } = await qb
148
+
149
+ const movements = (data ?? []).map((r: any) => {
150
+ const mov = snakeToCamel(r) as any
151
+ mov.productName = r.product_name ?? mov.productName
152
+ mov.productSku = r.product_sku ?? mov.productSku
153
+ // View may include location names if the updated migration has been applied
154
+ mov.stockLocationName = r.stock_location_name ?? mov.stockLocationName
155
+ mov.destinationLocationName = r.destination_location_name ?? mov.destinationLocationName
156
+ return mov as StockMovement
157
+ })
158
+
159
+ // Resolve location names if not provided by the view
160
+ const needsLocationResolve = movements.some(
161
+ (m: any) => (m.stockLocationId && !m.stockLocationName) || (m.destinationLocationId && !m.destinationLocationName)
162
+ )
163
+ if (needsLocationResolve) {
164
+ const locationIds = new Set<string>()
165
+ for (const m of movements) {
166
+ if (m.stockLocationId && !m.stockLocationName) locationIds.add(m.stockLocationId)
167
+ if (m.destinationLocationId && !m.destinationLocationName) locationIds.add(m.destinationLocationId)
168
+ }
169
+ if (locationIds.size > 0) {
170
+ const { data: locs } = await pub.from('stock_locations').select('id, name').in('id', [...locationIds])
171
+ const locMap = new Map((locs ?? []).map((l: any) => [l.id, l.name]))
172
+ for (const m of movements) {
173
+ if (m.stockLocationId && !m.stockLocationName) m.stockLocationName = locMap.get(m.stockLocationId)
174
+ if (m.destinationLocationId && !m.destinationLocationName) m.destinationLocationName = locMap.get(m.destinationLocationId)
175
+ }
176
+ }
177
+ }
178
+
179
+ return { data: movements, total: count ?? 0 }
180
+ },
181
+
182
+ async createMovement(input: CreateStockMovementInput): Promise<StockMovement> {
183
+ const { core, pub } = getClients()
184
+ const tenantId = getTenantId()
185
+ const unitCost = input.unitCost ?? 0
186
+ const row = {
187
+ ...camelToSnake(input as any),
188
+ tenant_id: tenantId,
189
+ unit_cost: unitCost,
190
+ total_cost: unitCost * input.quantity,
191
+ movement_date: input.movementDate ?? new Date().toISOString().slice(0, 10),
192
+ }
193
+ const { data, error } = await pub.from('stock_movements').insert(row).select().single()
194
+ if (error) throw new Error(error.message)
195
+
196
+ // Fetch product name + current stock for the response and stock update
197
+ const { data: product } = await core.from('products').select('id, name, stock').eq('id', input.productId).single()
198
+
199
+ // Update product stock
200
+ if (product) {
201
+ const delta = (input.movementType === 'entry') ? input.quantity : -(input.quantity)
202
+ if (input.movementType !== 'adjustment' && input.movementType !== 'transfer') {
203
+ await core.from('products').update({ stock: (product.stock ?? 0) + delta }).eq('id', input.productId)
204
+ }
205
+ }
206
+
207
+ // Update stock_positions when a location is specified (best-effort — don't fail the movement)
208
+ if (input.stockLocationId) {
209
+ try {
210
+ const posDelta = (input.movementType === 'entry') ? input.quantity : -(input.quantity)
211
+ const { data: existing } = await pub.from('stock_positions')
212
+ .select('id, quantity')
213
+ .eq('product_id', input.productId)
214
+ .eq('stock_location_id', input.stockLocationId)
215
+ .maybeSingle()
216
+
217
+ if (existing) {
218
+ await pub.from('stock_positions')
219
+ .update({ quantity: (existing.quantity ?? 0) + posDelta, unit_cost: input.unitCost ?? 0 })
220
+ .eq('id', existing.id)
221
+ } else {
222
+ await pub.from('stock_positions').insert({
223
+ tenant_id: tenantId,
224
+ product_id: input.productId,
225
+ stock_location_id: input.stockLocationId,
226
+ quantity: Math.max(0, posDelta),
227
+ unit_cost: input.unitCost ?? 0,
228
+ batch_number: input.batchNumber ?? null,
229
+ expiration_date: input.expirationDate ?? null,
230
+ })
231
+ }
232
+
233
+ // For transfers, also update the destination position
234
+ if (input.movementType === 'transfer' && input.destinationLocationId) {
235
+ const { data: destExisting } = await pub.from('stock_positions')
236
+ .select('id, quantity')
237
+ .eq('product_id', input.productId)
238
+ .eq('stock_location_id', input.destinationLocationId)
239
+ .maybeSingle()
240
+
241
+ if (destExisting) {
242
+ await pub.from('stock_positions')
243
+ .update({ quantity: (destExisting.quantity ?? 0) + input.quantity, unit_cost: input.unitCost ?? 0 })
244
+ .eq('id', destExisting.id)
245
+ } else {
246
+ await pub.from('stock_positions').insert({
247
+ tenant_id: tenantId,
248
+ product_id: input.productId,
249
+ stock_location_id: input.destinationLocationId,
250
+ quantity: input.quantity,
251
+ unit_cost: input.unitCost ?? 0,
252
+ batch_number: input.batchNumber ?? null,
253
+ expiration_date: input.expirationDate ?? null,
254
+ })
255
+ }
256
+ }
257
+ } catch {
258
+ // stock_positions update is best-effort — log but don't fail the movement
259
+ console.warn('Failed to update stock_positions — table may not exist yet')
260
+ }
261
+ }
262
+
263
+ const movement = snakeToCamel(data) as any
264
+ movement.productName = product?.name
265
+ // Resolve location name
266
+ if (input.stockLocationId) {
267
+ const { data: loc } = await pub.from('stock_locations').select('name').eq('id', input.stockLocationId).single()
268
+ movement.stockLocationName = loc?.name
269
+ }
270
+ if (input.destinationLocationId) {
271
+ const { data: loc } = await pub.from('stock_locations').select('name').eq('id', input.destinationLocationId).single()
272
+ movement.destinationLocationName = loc?.name
273
+ }
274
+ return movement as StockMovement
275
+ },
276
+
277
+ // --- Stock Positions ---
278
+ async getPositions(productId: string): Promise<StockPosition[]> {
279
+ const { pub } = getClients()
280
+ const { data } = await pub.from('stock_positions').select('*').eq('product_id', productId)
281
+ return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as StockPosition)
282
+ },
283
+
284
+ // --- Stock Locations ---
285
+ async getLocations(): Promise<StockLocation[]> {
286
+ const { pub } = getClients()
287
+ const { data } = await pub.from('stock_locations').select('*').eq('is_active', true).order('name')
288
+ return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as StockLocation)
289
+ },
290
+
291
+ async createLocation(input): Promise<StockLocation> {
292
+ const { pub } = getClients()
293
+ const tenantId = getTenantId()
294
+ const { data } = await pub.from('stock_locations').insert({ ...camelToSnake(input as any), tenant_id: tenantId }).select().single()
295
+ return snakeToCamel(data!) as unknown as StockLocation
296
+ },
297
+
298
+ // --- Recipes ---
299
+ async getRecipes(): Promise<Recipe[]> {
300
+ const { pub } = getClients()
301
+ const { data } = await pub.from('recipes').select('*').eq('is_active', true).order('name')
302
+ return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as Recipe)
303
+ },
304
+
305
+ async getRecipeById(id: string): Promise<Recipe | null> {
306
+ const { pub } = getClients()
307
+ const { data } = await pub.from('recipes').select('*').eq('id', id).single()
308
+ return data ? snakeToCamel(data) as unknown as Recipe : null
309
+ },
310
+
311
+ async getRecipeIngredients(recipeId: string): Promise<RecipeIngredient[]> {
312
+ const { pub } = getClients()
313
+ const { data } = await pub.from('recipe_ingredients').select('*').eq('recipe_id', recipeId).order('display_order')
314
+ return (data ?? []).map((r: any) => snakeToCamel(r) as unknown as RecipeIngredient)
315
+ },
316
+
317
+ async createRecipe(input: CreateRecipeInput): Promise<Recipe> {
318
+ const { pub } = getClients()
319
+ const tenantId = getTenantId()
320
+ const { ingredients, ...recipeData } = input
321
+ const { data: recipe } = await pub.from('recipes').insert({ ...camelToSnake(recipeData as any), tenant_id: tenantId }).select().single()
322
+ if (recipe && ingredients.length > 0) {
323
+ await pub.from('recipe_ingredients').insert(
324
+ ingredients.map((ing) => ({ ...camelToSnake(ing as any), recipe_id: recipe.id, tenant_id: tenantId }))
325
+ )
326
+ }
327
+ return snakeToCamel(recipe!) as unknown as Recipe
328
+ },
329
+
330
+ // --- Summary ---
331
+ async getSummary(): Promise<InventorySummary> {
332
+ const { core, pub } = getClients()
333
+ const { data: products } = await core.from('products').select('stock, min_stock, price, is_active').eq('is_active', true)
334
+ const items = products ?? []
335
+ const lowStock = items.filter((p: any) => p.stock > 0 && p.stock <= (p.min_stock ?? 0))
336
+ const outOfStock = items.filter((p: any) => p.stock <= 0)
337
+ const totalValue = items.reduce((sum: number, p: any) => sum + (p.stock ?? 0) * (p.price ?? 0), 0)
338
+
339
+ const weekAgo = new Date()
340
+ weekAgo.setDate(weekAgo.getDate() - 7)
341
+ const { data: movements } = await pub.from('stock_movements').select('movement_type').gte('movement_date', weekAgo.toISOString().slice(0, 10))
342
+ const movs = movements ?? []
343
+ const movementsByType: Record<MovementType, number> = { entry: 0, exit: 0, adjustment: 0, transfer: 0, loss: 0 }
344
+ for (const m of movs) movementsByType[m.movement_type as MovementType]++
345
+
346
+ return {
347
+ totalProducts: items.length,
348
+ lowStockCount: lowStock.length,
349
+ outOfStockCount: outOfStock.length,
350
+ totalStockValue: totalValue,
351
+ recentMovementCount: movs.length,
352
+ movementsByType,
353
+ }
354
+ },
355
+ }
356
+
357
+ return provider
358
+ }
@@ -0,0 +1,35 @@
1
+ import type {
2
+ Product, StockMovement, StockPosition, StockLocation,
3
+ Recipe, RecipeIngredient,
4
+ CreateProductInput, CreateStockMovementInput, CreateRecipeInput,
5
+ ProductQuery, MovementQuery,
6
+ PaginatedResult, InventorySummary,
7
+ } from '../types'
8
+
9
+ export interface InventoryDataProvider {
10
+ // --- Products ---
11
+ getProducts(query: ProductQuery): Promise<PaginatedResult<Product>>
12
+ getProductById(id: string): Promise<Product | null>
13
+ createProduct(input: CreateProductInput): Promise<Product>
14
+ updateProduct(id: string, data: Partial<Product>): Promise<Product>
15
+
16
+ // --- Stock Movements ---
17
+ getMovements(query: MovementQuery): Promise<PaginatedResult<StockMovement>>
18
+ createMovement(input: CreateStockMovementInput): Promise<StockMovement>
19
+
20
+ // --- Stock Positions ---
21
+ getPositions(productId: string): Promise<StockPosition[]>
22
+
23
+ // --- Stock Locations ---
24
+ getLocations(): Promise<StockLocation[]>
25
+ createLocation(data: { name: string; description?: string; unitId?: string }): Promise<StockLocation>
26
+
27
+ // --- Recipes ---
28
+ getRecipes(): Promise<Recipe[]>
29
+ getRecipeById(id: string): Promise<Recipe | null>
30
+ getRecipeIngredients(recipeId: string): Promise<RecipeIngredient[]>
31
+ createRecipe(input: CreateRecipeInput): Promise<Recipe>
32
+
33
+ // --- Summary ---
34
+ getSummary(): Promise<InventorySummary>
35
+ }