@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/types.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Inventory Plugin — Pure TypeScript types
|
|
3
|
+
// Zero dependencies. Abstracted from beautyplace Estoque module.
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
|
|
6
|
+
// ============================================================
|
|
7
|
+
// ENUMS / LITERALS
|
|
8
|
+
// ============================================================
|
|
9
|
+
|
|
10
|
+
/** Product classification — a product can have multiple types */
|
|
11
|
+
export type ProductType = 'ingredient' | 'sale' | 'intermediate' | 'asset'
|
|
12
|
+
|
|
13
|
+
/** Stock movement direction/reason */
|
|
14
|
+
export type MovementType = 'entry' | 'exit' | 'adjustment' | 'transfer' | 'loss'
|
|
15
|
+
|
|
16
|
+
/** Product purpose for filtering */
|
|
17
|
+
export type ProductPurpose = 'purchase' | 'sale' | 'both' | 'internal'
|
|
18
|
+
|
|
19
|
+
// ============================================================
|
|
20
|
+
// CORE ENTITIES
|
|
21
|
+
// ============================================================
|
|
22
|
+
|
|
23
|
+
export interface Product {
|
|
24
|
+
id: string
|
|
25
|
+
name: string
|
|
26
|
+
description?: string
|
|
27
|
+
sku?: string
|
|
28
|
+
barcode?: string
|
|
29
|
+
brand?: string
|
|
30
|
+
categoryId?: string
|
|
31
|
+
categoryName?: string
|
|
32
|
+
productType: ProductType
|
|
33
|
+
purpose?: ProductPurpose
|
|
34
|
+
currentQuantity: number
|
|
35
|
+
minQuantity: number
|
|
36
|
+
maxQuantity?: number
|
|
37
|
+
costPrice: number
|
|
38
|
+
salePrice?: number
|
|
39
|
+
measurementUnitId?: string
|
|
40
|
+
measurementUnitName?: string
|
|
41
|
+
isActive: boolean
|
|
42
|
+
supplierId?: string
|
|
43
|
+
supplierName?: string
|
|
44
|
+
defaultLocationId?: string
|
|
45
|
+
imageUrl?: string
|
|
46
|
+
metadata?: Record<string, unknown>
|
|
47
|
+
tenantId: string
|
|
48
|
+
createdAt: string
|
|
49
|
+
updatedAt: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface StockMovement {
|
|
53
|
+
id: string
|
|
54
|
+
productId: string
|
|
55
|
+
productName?: string
|
|
56
|
+
quantity: number
|
|
57
|
+
movementType: MovementType
|
|
58
|
+
unitCost: number
|
|
59
|
+
totalCost: number
|
|
60
|
+
stockLocationId?: string
|
|
61
|
+
stockLocationName?: string
|
|
62
|
+
destinationLocationId?: string
|
|
63
|
+
destinationLocationName?: string
|
|
64
|
+
supplierId?: string
|
|
65
|
+
supplierName?: string
|
|
66
|
+
documentNumber?: string
|
|
67
|
+
reason?: string
|
|
68
|
+
notes?: string
|
|
69
|
+
movementDate: string
|
|
70
|
+
userId?: string
|
|
71
|
+
userName?: string
|
|
72
|
+
metadata?: Record<string, unknown>
|
|
73
|
+
tenantId: string
|
|
74
|
+
createdAt: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface StockPosition {
|
|
78
|
+
id: string
|
|
79
|
+
productId: string
|
|
80
|
+
productName?: string
|
|
81
|
+
quantity: number
|
|
82
|
+
unitCost: number
|
|
83
|
+
stockLocationId?: string
|
|
84
|
+
stockLocationName?: string
|
|
85
|
+
batchNumber?: string
|
|
86
|
+
expirationDate?: string
|
|
87
|
+
tenantId: string
|
|
88
|
+
createdAt: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface StockLocation {
|
|
92
|
+
id: string
|
|
93
|
+
name: string
|
|
94
|
+
description?: string
|
|
95
|
+
isActive: boolean
|
|
96
|
+
unitId?: string
|
|
97
|
+
tenantId: string
|
|
98
|
+
createdAt: string
|
|
99
|
+
updatedAt: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface Recipe {
|
|
103
|
+
id: string
|
|
104
|
+
name: string
|
|
105
|
+
description?: string
|
|
106
|
+
productId: string
|
|
107
|
+
productName?: string
|
|
108
|
+
yieldQuantity: number
|
|
109
|
+
yieldUnitId?: string
|
|
110
|
+
yieldUnitName?: string
|
|
111
|
+
preparationTimeMinutes?: number
|
|
112
|
+
instructions?: string
|
|
113
|
+
isActive: boolean
|
|
114
|
+
ingredientCount?: number
|
|
115
|
+
tenantId: string
|
|
116
|
+
createdAt: string
|
|
117
|
+
updatedAt: string
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface RecipeIngredient {
|
|
121
|
+
id: string
|
|
122
|
+
recipeId: string
|
|
123
|
+
productId: string
|
|
124
|
+
productName?: string
|
|
125
|
+
quantity: number
|
|
126
|
+
unitId?: string
|
|
127
|
+
unitName?: string
|
|
128
|
+
displayOrder: number
|
|
129
|
+
notes?: string
|
|
130
|
+
createdAt: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface MeasurementUnit {
|
|
134
|
+
id: string
|
|
135
|
+
name: string
|
|
136
|
+
abbreviation: string
|
|
137
|
+
isActive: boolean
|
|
138
|
+
tenantId: string
|
|
139
|
+
createdAt: string
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface ProductCategory {
|
|
143
|
+
id: string
|
|
144
|
+
name: string
|
|
145
|
+
parentId?: string
|
|
146
|
+
isActive: boolean
|
|
147
|
+
tenantId: string
|
|
148
|
+
createdAt: string
|
|
149
|
+
updatedAt: string
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ============================================================
|
|
153
|
+
// INPUT TYPES
|
|
154
|
+
// ============================================================
|
|
155
|
+
|
|
156
|
+
export interface CreateProductInput {
|
|
157
|
+
name: string
|
|
158
|
+
description?: string
|
|
159
|
+
sku?: string
|
|
160
|
+
barcode?: string
|
|
161
|
+
brand?: string
|
|
162
|
+
categoryId?: string
|
|
163
|
+
productType: ProductType
|
|
164
|
+
purpose?: ProductPurpose
|
|
165
|
+
minQuantity?: number
|
|
166
|
+
maxQuantity?: number
|
|
167
|
+
costPrice?: number
|
|
168
|
+
salePrice?: number
|
|
169
|
+
measurementUnitId?: string
|
|
170
|
+
supplierId?: string
|
|
171
|
+
defaultLocationId?: string
|
|
172
|
+
imageUrl?: string
|
|
173
|
+
metadata?: Record<string, unknown>
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface CreateStockMovementInput {
|
|
177
|
+
productId: string
|
|
178
|
+
quantity: number
|
|
179
|
+
movementType: MovementType
|
|
180
|
+
unitCost?: number
|
|
181
|
+
stockLocationId?: string
|
|
182
|
+
destinationLocationId?: string
|
|
183
|
+
supplierId?: string
|
|
184
|
+
documentNumber?: string
|
|
185
|
+
reason?: string
|
|
186
|
+
notes?: string
|
|
187
|
+
movementDate?: string
|
|
188
|
+
batchNumber?: string
|
|
189
|
+
expirationDate?: string
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface CreateRecipeInput {
|
|
193
|
+
name: string
|
|
194
|
+
description?: string
|
|
195
|
+
productId: string
|
|
196
|
+
yieldQuantity: number
|
|
197
|
+
yieldUnitId?: string
|
|
198
|
+
preparationTimeMinutes?: number
|
|
199
|
+
instructions?: string
|
|
200
|
+
ingredients: CreateRecipeIngredientInput[]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface CreateRecipeIngredientInput {
|
|
204
|
+
productId: string
|
|
205
|
+
quantity: number
|
|
206
|
+
unitId?: string
|
|
207
|
+
displayOrder?: number
|
|
208
|
+
notes?: string
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ============================================================
|
|
212
|
+
// QUERY / FILTER TYPES
|
|
213
|
+
// ============================================================
|
|
214
|
+
|
|
215
|
+
export interface DateRange {
|
|
216
|
+
from: string
|
|
217
|
+
to: string
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface ProductQuery {
|
|
221
|
+
productType?: ProductType
|
|
222
|
+
categoryId?: string
|
|
223
|
+
search?: string
|
|
224
|
+
lowStockOnly?: boolean
|
|
225
|
+
isActive?: boolean
|
|
226
|
+
page?: number
|
|
227
|
+
pageSize?: number
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface MovementQuery {
|
|
231
|
+
productId?: string
|
|
232
|
+
movementType?: MovementType | MovementType[]
|
|
233
|
+
stockLocationId?: string
|
|
234
|
+
dateRange?: DateRange
|
|
235
|
+
search?: string
|
|
236
|
+
page?: number
|
|
237
|
+
pageSize?: number
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface PaginatedResult<T> {
|
|
241
|
+
data: T[]
|
|
242
|
+
total: number
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ============================================================
|
|
246
|
+
// AGGREGATION TYPES
|
|
247
|
+
// ============================================================
|
|
248
|
+
|
|
249
|
+
export interface InventorySummary {
|
|
250
|
+
totalProducts: number
|
|
251
|
+
lowStockCount: number
|
|
252
|
+
outOfStockCount: number
|
|
253
|
+
totalStockValue: number
|
|
254
|
+
recentMovementCount: number
|
|
255
|
+
movementsByType: Record<MovementType, number>
|
|
256
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { DashboardCanvas } from '@fayz-ai/ui'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Inventory overview. KPIs and the activity panel are registered dashboard
|
|
6
|
+
* widgets (see ./dashboardWidgets), rendered through the shared DashboardCanvas;
|
|
7
|
+
* the stock-value KPI also surfaces on the global app home.
|
|
8
|
+
*/
|
|
9
|
+
export function DashboardView() {
|
|
10
|
+
return <DashboardCanvas surface="plugin-home" domain="inventory" showHeader={false} className="space-y-6" />
|
|
11
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import React, { useEffect, useState, useMemo } from 'react'
|
|
2
|
+
import { ArrowUpRight, ArrowDownRight, RefreshCw, ArrowRightLeft, Trash2 } from 'lucide-react'
|
|
3
|
+
import type { ColumnDef } from '@tanstack/react-table'
|
|
4
|
+
import { ListView } from '@fayz-ai/ui'
|
|
5
|
+
import { useInventoryStore, formatCurrency, useInventoryConfig } from '../InventoryContext'
|
|
6
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
7
|
+
import { SubpageHeader } from '@fayz-ai/ui'
|
|
8
|
+
import type { MovementType, StockMovement } from '../types'
|
|
9
|
+
|
|
10
|
+
const TYPE_ICONS: Record<MovementType, React.ElementType> = {
|
|
11
|
+
entry: ArrowDownRight,
|
|
12
|
+
exit: ArrowUpRight,
|
|
13
|
+
adjustment: RefreshCw,
|
|
14
|
+
transfer: ArrowRightLeft,
|
|
15
|
+
loss: Trash2,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const TYPE_COLORS: Record<MovementType, string> = {
|
|
19
|
+
entry: 'text-success',
|
|
20
|
+
exit: 'text-destructive',
|
|
21
|
+
adjustment: 'text-info',
|
|
22
|
+
transfer: 'text-magic',
|
|
23
|
+
loss: 'text-warning',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function MovementHistoryView({ onViewDetail }: { onViewDetail?: (id: string) => void } = {}) {
|
|
27
|
+
const t = useTranslation()
|
|
28
|
+
const { currency } = useInventoryConfig()
|
|
29
|
+
const movements = useInventoryStore((s) => s.movements)
|
|
30
|
+
const movementsLoading = useInventoryStore((s) => s.movementsLoading)
|
|
31
|
+
const fetchMovements = useInventoryStore((s) => s.fetchMovements)
|
|
32
|
+
|
|
33
|
+
const [search, setSearch] = useState('')
|
|
34
|
+
|
|
35
|
+
useEffect(() => { fetchMovements({ search: search || undefined }) }, [search])
|
|
36
|
+
|
|
37
|
+
const columns: ColumnDef<StockMovement, any>[] = useMemo(() => [
|
|
38
|
+
{
|
|
39
|
+
accessorKey: 'movementDate', header: t('inventory.history.columnDate'),
|
|
40
|
+
cell: ({ getValue }: any) => <span className="text-xs text-muted-foreground">{getValue() as string}</span>,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
accessorKey: 'productName', header: t('inventory.history.columnProduct'),
|
|
44
|
+
cell: ({ getValue }: any) => <span className="font-medium">{(getValue() as string) ?? '—'}</span>,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
accessorKey: 'movementType', header: t('inventory.history.columnType'),
|
|
48
|
+
cell: ({ getValue }: any) => {
|
|
49
|
+
const type = getValue() as MovementType
|
|
50
|
+
const Icon = TYPE_ICONS[type] ?? RefreshCw
|
|
51
|
+
const color = TYPE_COLORS[type] ?? 'text-muted-foreground'
|
|
52
|
+
return (
|
|
53
|
+
<span className={`inline-flex items-center gap-1 ${color}`}>
|
|
54
|
+
<Icon className="h-3 w-3" />
|
|
55
|
+
<span className="text-xs capitalize">{type}</span>
|
|
56
|
+
</span>
|
|
57
|
+
)
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'location', header: t('inventory.history.columnLocation'),
|
|
62
|
+
cell: ({ row }: any) => (
|
|
63
|
+
<span className="text-xs text-muted-foreground">
|
|
64
|
+
{row.original.stockLocationName ?? '—'}
|
|
65
|
+
{row.original.movementType === 'transfer' && row.original.destinationLocationName && (
|
|
66
|
+
<span> → {row.original.destinationLocationName}</span>
|
|
67
|
+
)}
|
|
68
|
+
</span>
|
|
69
|
+
),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
accessorKey: 'quantity', header: t('inventory.history.columnQty'),
|
|
73
|
+
cell: ({ row }: any) => (
|
|
74
|
+
<span className="text-right block">
|
|
75
|
+
{row.original.movementType === 'entry' ? '+' : '-'}{row.original.quantity}
|
|
76
|
+
</span>
|
|
77
|
+
),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
accessorKey: 'totalCost', header: t('inventory.history.columnTotal'),
|
|
81
|
+
cell: ({ getValue }: any) => (
|
|
82
|
+
<span className="text-right block text-muted-foreground">{formatCurrency(getValue() as number, currency)}</span>
|
|
83
|
+
),
|
|
84
|
+
},
|
|
85
|
+
], [currency, t])
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className="space-y-4">
|
|
89
|
+
<SubpageHeader title={t('inventory.history.title')} subtitle={t('inventory.history.subtitle')} />
|
|
90
|
+
|
|
91
|
+
<ListView<StockMovement>
|
|
92
|
+
columns={columns}
|
|
93
|
+
data={movements}
|
|
94
|
+
loading={movementsLoading}
|
|
95
|
+
searchPlaceholder={t('inventory.history.searchPlaceholder')}
|
|
96
|
+
search={search}
|
|
97
|
+
onSearchChange={setSearch}
|
|
98
|
+
searchDebounce={0}
|
|
99
|
+
onRowClick={onViewDetail ? (row) => onViewDetail(row.id) : undefined}
|
|
100
|
+
emptyMessage={t('inventory.history.noMovements')}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { SubpageHeader, toast } from '@fayz-ai/ui'
|
|
3
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
4
|
+
import { CrudFormPage } from '@fayz-ai/saas'
|
|
5
|
+
import { useInventoryConfig, useInventoryStore, useInventoryProvider } from '../InventoryContext'
|
|
6
|
+
import type { ProductType } from '../types'
|
|
7
|
+
import { buildProductEntity } from './productEntity'
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Product create/edit, rendered through the generic CRUD form. The sectioned
|
|
11
|
+
// layout (General Information · Classification · Pricing+Margin · Stock Levels)
|
|
12
|
+
// comes from the shared buildProductEntity (segmented/computed/currency field
|
|
13
|
+
// types). Persistence still flows through the inventory provider/store, so the
|
|
14
|
+
// metadata-JSON column mapping is unchanged.
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export function ProductCrudForm({ editId, onSaved }: { editId?: string; onSaved?: () => void }) {
|
|
18
|
+
const t = useTranslation()
|
|
19
|
+
const { productTypes, currency } = useInventoryConfig()
|
|
20
|
+
const provider = useInventoryProvider()
|
|
21
|
+
const createProduct = useInventoryStore((s) => s.createProduct)
|
|
22
|
+
const isEdit = !!editId
|
|
23
|
+
|
|
24
|
+
const [initialData, setInitialData] = React.useState<Record<string, any> | null>(isEdit ? null : {})
|
|
25
|
+
const [headerName, setHeaderName] = React.useState('')
|
|
26
|
+
|
|
27
|
+
// Load the existing product for edit and map it to flat form values.
|
|
28
|
+
React.useEffect(() => {
|
|
29
|
+
if (!editId) return
|
|
30
|
+
let cancelled = false
|
|
31
|
+
;(async () => {
|
|
32
|
+
const p = await provider.getProductById(editId)
|
|
33
|
+
if (cancelled) return
|
|
34
|
+
setHeaderName(p?.name ?? '')
|
|
35
|
+
setInitialData(
|
|
36
|
+
p
|
|
37
|
+
? {
|
|
38
|
+
name: p.name,
|
|
39
|
+
brand: p.brand ?? '',
|
|
40
|
+
sku: p.sku ?? '',
|
|
41
|
+
barcode: p.barcode ?? '',
|
|
42
|
+
description: p.description ?? '',
|
|
43
|
+
productType: p.productType,
|
|
44
|
+
costPrice: p.costPrice,
|
|
45
|
+
salePrice: p.salePrice ?? 0,
|
|
46
|
+
minQuantity: p.minQuantity,
|
|
47
|
+
maxQuantity: p.maxQuantity ?? 0,
|
|
48
|
+
}
|
|
49
|
+
: {},
|
|
50
|
+
)
|
|
51
|
+
})()
|
|
52
|
+
return () => { cancelled = true }
|
|
53
|
+
}, [editId])
|
|
54
|
+
|
|
55
|
+
const entity = React.useMemo(() => buildProductEntity(t, productTypes, currency), [t, productTypes, currency])
|
|
56
|
+
|
|
57
|
+
async function save(values: Record<string, any>) {
|
|
58
|
+
const name = String(values.name ?? '').trim()
|
|
59
|
+
if (!name) { toast.error(t('common.formIncomplete')); throw new Error('name required') }
|
|
60
|
+
const payload = {
|
|
61
|
+
name,
|
|
62
|
+
sku: values.sku || undefined,
|
|
63
|
+
barcode: values.barcode || undefined,
|
|
64
|
+
brand: values.brand || undefined,
|
|
65
|
+
productType: (values.productType ?? productTypes[0]?.value) as ProductType,
|
|
66
|
+
costPrice: Number(values.costPrice) || 0,
|
|
67
|
+
salePrice: Number(values.salePrice) || undefined,
|
|
68
|
+
minQuantity: Number(values.minQuantity) || 0,
|
|
69
|
+
maxQuantity: Number(values.maxQuantity) || undefined,
|
|
70
|
+
description: values.description || undefined,
|
|
71
|
+
}
|
|
72
|
+
if (isEdit && editId) {
|
|
73
|
+
await provider.updateProduct(editId, payload)
|
|
74
|
+
} else {
|
|
75
|
+
await createProduct(payload)
|
|
76
|
+
}
|
|
77
|
+
onSaved?.()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const title = isEdit ? (headerName || t('inventory.productForm.editProduct')) : t('inventory.productForm.newProduct')
|
|
81
|
+
const subtitle = isEdit ? undefined : t('inventory.productForm.addToCatalog')
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="space-y-5">
|
|
85
|
+
<SubpageHeader title={title} subtitle={subtitle} onBack={onSaved} parentLabel={t('inventory.nav.products')} />
|
|
86
|
+
{initialData && (
|
|
87
|
+
<CrudFormPage
|
|
88
|
+
entityDef={entity}
|
|
89
|
+
mode={isEdit ? 'edit' : 'create'}
|
|
90
|
+
initialData={initialData}
|
|
91
|
+
onSubmit={save}
|
|
92
|
+
onCancel={() => onSaved?.()}
|
|
93
|
+
namePlural={t('inventory.nav.products')}
|
|
94
|
+
hideBreadcrumb
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
}
|