@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.
- package/dist/InventoryContext.d.ts +37 -0
- package/dist/InventoryContext.d.ts.map +1 -0
- package/dist/InventoryPage.d.ts +13 -0
- package/dist/InventoryPage.d.ts.map +1 -0
- package/dist/components/InventoryGeneralSettings.d.ts +3 -0
- package/dist/components/InventoryGeneralSettings.d.ts.map +1 -0
- package/dist/components/InventoryOnboarding.d.ts +5 -0
- package/dist/components/InventoryOnboarding.d.ts.map +1 -0
- package/dist/components/InventorySettings.d.ts +8 -0
- package/dist/components/InventorySettings.d.ts.map +1 -0
- package/dist/data/index.d.ts +3 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/mock.d.ts +3 -0
- package/dist/data/mock.d.ts.map +1 -0
- package/dist/data/supabase.d.ts +3 -0
- package/dist/data/supabase.d.ts.map +1 -0
- package/dist/data/types.d.ts +22 -0
- package/dist/data/types.d.ts.map +1 -0
- package/dist/index.cjs +2936 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2930 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/tenant.d.ts +3 -0
- package/dist/lib/tenant.d.ts.map +1 -0
- package/dist/locales/en.d.ts +2 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/index.d.ts +2 -0
- package/dist/locales/index.d.ts.map +1 -0
- package/dist/locales/pt-BR.d.ts +2 -0
- package/dist/locales/pt-BR.d.ts.map +1 -0
- package/dist/registries.d.ts +3 -0
- package/dist/registries.d.ts.map +1 -0
- package/dist/store.d.ts +28 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/types.d.ts +213 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/views/DashboardView.d.ts +8 -0
- package/dist/views/DashboardView.d.ts.map +1 -0
- package/dist/views/MovementHistoryView.d.ts +5 -0
- package/dist/views/MovementHistoryView.d.ts.map +1 -0
- package/dist/views/ProductCrudForm.d.ts +6 -0
- package/dist/views/ProductCrudForm.d.ts.map +1 -0
- package/dist/views/ProductFormView.d.ts +6 -0
- package/dist/views/ProductFormView.d.ts.map +1 -0
- package/dist/views/ProductListView.d.ts +6 -0
- package/dist/views/ProductListView.d.ts.map +1 -0
- package/dist/views/RecipeDetailView.d.ts +6 -0
- package/dist/views/RecipeDetailView.d.ts.map +1 -0
- package/dist/views/RecipeFormView.d.ts +5 -0
- package/dist/views/RecipeFormView.d.ts.map +1 -0
- package/dist/views/RecipesView.d.ts +6 -0
- package/dist/views/RecipesView.d.ts.map +1 -0
- package/dist/views/StockMovementView.d.ts +8 -0
- package/dist/views/StockMovementView.d.ts.map +1 -0
- package/dist/views/dashboardWidgets.d.ts +11 -0
- package/dist/views/dashboardWidgets.d.ts.map +1 -0
- package/dist/views/productEntity.d.ts +6 -0
- package/dist/views/productEntity.d.ts.map +1 -0
- package/package.json +55 -0
- package/src/InventoryContext.tsx +40 -0
- package/src/InventoryPage.tsx +170 -0
- package/src/README.md +177 -0
- package/src/components/InventoryGeneralSettings.tsx +26 -0
- package/src/components/InventoryOnboarding.tsx +60 -0
- package/src/components/InventorySettings.tsx +27 -0
- package/src/data/index.ts +2 -0
- package/src/data/mock.ts +266 -0
- package/src/data/supabase.ts +358 -0
- package/src/data/types.ts +35 -0
- package/src/index.ts +191 -0
- package/src/lib/tenant.ts +4 -0
- package/src/locales/en.ts +242 -0
- package/src/locales/index.ts +7 -0
- package/src/locales/pt-BR.ts +242 -0
- package/src/migrations/001_inventory_base.sql +69 -0
- package/src/migrations/002_recipes.sql +34 -0
- package/src/migrations/003_measurement_units.sql +13 -0
- package/src/registries.ts +111 -0
- package/src/store.ts +127 -0
- package/src/types.ts +256 -0
- package/src/views/DashboardView.tsx +11 -0
- package/src/views/MovementHistoryView.tsx +104 -0
- package/src/views/ProductCrudForm.tsx +99 -0
- package/src/views/ProductFormView.tsx +283 -0
- package/src/views/ProductListView.tsx +107 -0
- package/src/views/RecipeDetailView.tsx +192 -0
- package/src/views/RecipeFormView.tsx +235 -0
- package/src/views/RecipesView.tsx +103 -0
- package/src/views/StockMovementView.tsx +516 -0
- package/src/views/dashboardWidgets.tsx +101 -0
- package/src/views/productEntity.tsx +124 -0
package/src/data/mock.ts
ADDED
|
@@ -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
|
+
}
|