@fayz-ai/plugin-inventory 0.1.1 → 0.1.3
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/LICENSE +21 -0
- package/README.md +50 -0
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/index.cjs +265 -365
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +226 -326
- package/dist/index.js.map +1 -1
- package/dist/views/ProductListView.d.ts.map +1 -1
- package/dist/views/StockMovementView.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/InventoryPage.tsx +3 -3
- package/src/data/supabase.ts +2 -0
- package/src/views/ProductListView.tsx +34 -84
- package/src/views/StockMovementView.tsx +18 -16
- package/dist/views/ProductFormView.d.ts +0 -6
- package/dist/views/ProductFormView.d.ts.map +0 -1
- package/src/views/ProductFormView.tsx +0 -283
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ProductListView.d.ts","sourceRoot":"","sources":["../../src/views/ProductListView.tsx"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"ProductListView.d.ts","sourceRoot":"","sources":["../../src/views/ProductListView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAuC,MAAM,OAAO,CAAA;AAW3D,wBAAgB,eAAe,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE;IACjD,KAAK,CAAC,EAAE,MAAM,IAAI,CAAA;IAClB,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;CAC9B,qBA0CA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"StockMovementView.d.ts","sourceRoot":"","sources":["../../src/views/StockMovementView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAuC,MAAM,OAAO,CAAA;AAQ3D,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAA;AAgH1E,wBAAgB,iBAAiB,CAAC,EAAE,WAAW,EAAE,OAAO,EAAE,YAAY,EAAE,EAAE;IACxE,WAAW,EAAE,YAAY,CAAA;IACzB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,YAAY,CAAC,EAAE,aAAa,CAAA;CAC7B,
|
|
1
|
+
{"version":3,"file":"StockMovementView.d.ts","sourceRoot":"","sources":["../../src/views/StockMovementView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAuC,MAAM,OAAO,CAAA;AAQ3D,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAA;AAgH1E,wBAAgB,iBAAiB,CAAC,EAAE,WAAW,EAAE,OAAO,EAAE,YAAY,EAAE,EAAE;IACxE,WAAW,EAAE,YAAY,CAAA;IACzB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;IACpB,YAAY,CAAC,EAAE,aAAa,CAAA;CAC7B,qBAyYA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fayz-ai/plugin-inventory",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Fayz SDK — plugin-inventory plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
"lucide-react": "^0.400.0",
|
|
27
27
|
"zustand": "^4.5.0",
|
|
28
28
|
"@tanstack/react-table": "^8.20.0",
|
|
29
|
-
"@fayz-ai/core": "^0.
|
|
30
|
-
"@fayz-ai/
|
|
31
|
-
"@fayz-ai/
|
|
29
|
+
"@fayz-ai/core": "^0.2.0",
|
|
30
|
+
"@fayz-ai/ui": "^0.2.0",
|
|
31
|
+
"@fayz-ai/saas": "^0.2.0"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/react": "^18.3.0",
|
package/src/InventoryPage.tsx
CHANGED
|
@@ -9,7 +9,7 @@ import { useModuleNavigation, ModuleActionBar, createViewRouter } from '@fayz-ai
|
|
|
9
9
|
import { useTranslation } from '@fayz-ai/core'
|
|
10
10
|
import { DashboardView } from './views/DashboardView'
|
|
11
11
|
import { ProductListView } from './views/ProductListView'
|
|
12
|
-
import {
|
|
12
|
+
import { ProductCrudForm } from './views/ProductCrudForm'
|
|
13
13
|
import { StockMovementView } from './views/StockMovementView'
|
|
14
14
|
import { MovementHistoryView } from './views/MovementHistoryView'
|
|
15
15
|
import { RecipesView } from './views/RecipesView'
|
|
@@ -129,8 +129,8 @@ export function InventoryPage({ config, provider, store, registries }: {
|
|
|
129
129
|
|
|
130
130
|
const renderView = createViewRouter([
|
|
131
131
|
{ id: 'products-list', render: () => <ProductListView onNew={() => navigate('products-new')} onEdit={(id) => navigate(`products-edit:${id}`)} /> },
|
|
132
|
-
{ id: 'products-new', render: () => <
|
|
133
|
-
{ id: 'products-edit', render: ({ id }) => <
|
|
132
|
+
{ id: 'products-new', render: () => <ProductCrudForm onSaved={() => navigate('products-list')} /> },
|
|
133
|
+
{ id: 'products-edit', render: ({ id }) => <ProductCrudForm editId={id!} onSaved={() => navigate('products-list')} /> },
|
|
134
134
|
{ id: 'stock-entry', render: () => <StockMovementView defaultType="entry" onSaved={() => navigate('stock-history')} /> },
|
|
135
135
|
{ id: 'stock-exit', render: () => <StockMovementView defaultType="exit" onSaved={() => navigate('stock-history')} /> },
|
|
136
136
|
{ id: 'stock-history', render: () => <MovementHistoryView onViewDetail={(id) => navigate(`stock-detail:${id}`)} /> },
|
package/src/data/supabase.ts
CHANGED
|
@@ -73,6 +73,8 @@ export function createSupabaseInventoryProvider(): InventoryDataProvider {
|
|
|
73
73
|
const { core, pub } = getClients()
|
|
74
74
|
let qb = core.from('products').select('*', { count: 'exact' })
|
|
75
75
|
if (query.search) qb = qb.ilike('name', `%${query.search}%`)
|
|
76
|
+
// productType lives in the metadata JSON column → filter on metadata->>productType.
|
|
77
|
+
if (query.productType) qb = qb.eq('metadata->>productType', query.productType)
|
|
76
78
|
if (query.isActive !== undefined) qb = qb.eq('is_active', query.isActive)
|
|
77
79
|
if (query.lowStockOnly) qb = qb.lte('stock', 0) // simplified
|
|
78
80
|
const page = query.page ?? 1
|
|
@@ -1,64 +1,14 @@
|
|
|
1
|
-
import React, { useEffect, useState } from 'react'
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import { ListView } from '@fayz-ai/ui'
|
|
5
|
-
import { useInventoryConfig, useInventoryStore, formatCurrency } from '../InventoryContext'
|
|
6
|
-
import { SubpageHeader } from '@fayz-ai/ui'
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { CrudListView } from '@fayz-ai/saas'
|
|
3
|
+
import { useInventoryConfig, useInventoryStore } from '../InventoryContext'
|
|
7
4
|
import { useTranslation } from '@fayz-ai/core'
|
|
8
|
-
import type { ProductType
|
|
9
|
-
|
|
10
|
-
const useColumns = (currency: { code: string; locale: string; symbol: string }, t: (key: string) => string): ColumnDef<Product, any>[] => [
|
|
11
|
-
{
|
|
12
|
-
accessorKey: 'name',
|
|
13
|
-
header: t('inventory.productList.product'),
|
|
14
|
-
enableSorting: true,
|
|
15
|
-
cell: ({ row }: any) => (
|
|
16
|
-
<div>
|
|
17
|
-
<p className="font-medium">{row.original.name}</p>
|
|
18
|
-
{row.original.sku && <p className="text-xs text-muted-foreground">{row.original.sku}</p>}
|
|
19
|
-
</div>
|
|
20
|
-
),
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
accessorKey: 'productType',
|
|
24
|
-
header: t('inventory.productList.type'),
|
|
25
|
-
cell: ({ getValue }: any) => (
|
|
26
|
-
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium bg-muted text-muted-foreground capitalize">
|
|
27
|
-
{getValue() as string}
|
|
28
|
-
</span>
|
|
29
|
-
),
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
accessorKey: 'currentQuantity',
|
|
33
|
-
header: t('inventory.productList.stock'),
|
|
34
|
-
enableSorting: true,
|
|
35
|
-
meta: { align: 'right' },
|
|
36
|
-
cell: ({ row }: any) => (
|
|
37
|
-
<span className={`text-right block ${row.original.currentQuantity <= row.original.minQuantity ? 'text-destructive font-medium' : ''}`}>
|
|
38
|
-
{row.original.currentQuantity}
|
|
39
|
-
</span>
|
|
40
|
-
),
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
accessorKey: 'costPrice',
|
|
44
|
-
header: t('inventory.productList.cost'),
|
|
45
|
-
meta: { align: 'right' },
|
|
46
|
-
cell: ({ getValue }: any) => (
|
|
47
|
-
<span className="text-right block text-muted-foreground">{formatCurrency(getValue() as number, currency)}</span>
|
|
48
|
-
),
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
id: 'value',
|
|
52
|
-
header: t('inventory.productList.value'),
|
|
53
|
-
meta: { align: 'right' },
|
|
54
|
-
cell: ({ row }: any) => (
|
|
55
|
-
<span className="text-right block font-medium">
|
|
56
|
-
{formatCurrency(row.original.currentQuantity * row.original.costPrice, currency)}
|
|
57
|
-
</span>
|
|
58
|
-
),
|
|
59
|
-
},
|
|
60
|
-
]
|
|
5
|
+
import type { ProductType } from '../types'
|
|
6
|
+
import { buildProductEntity } from './productEntity'
|
|
61
7
|
|
|
8
|
+
// Product list, rendered through the generic CrudListView (header · search ·
|
|
9
|
+
// facet pills · table). One shared EntityDef (buildProductEntity) drives both
|
|
10
|
+
// this list and the create/edit form. Data + routing stay in the inventory
|
|
11
|
+
// store / module nav.
|
|
62
12
|
export function ProductListView({ onNew, onEdit }: {
|
|
63
13
|
onNew?: () => void
|
|
64
14
|
onEdit?: (id: string) => void
|
|
@@ -72,36 +22,36 @@ export function ProductListView({ onNew, onEdit }: {
|
|
|
72
22
|
|
|
73
23
|
const [search, setSearch] = useState('')
|
|
74
24
|
const [typeFilter, setTypeFilter] = useState<string | undefined>()
|
|
25
|
+
const [loadedOnce, setLoadedOnce] = useState(false)
|
|
75
26
|
|
|
76
27
|
useEffect(() => {
|
|
77
|
-
|
|
28
|
+
let active = true
|
|
29
|
+
Promise.resolve(fetchProducts({ productType: typeFilter as ProductType | undefined, search: search || undefined }))
|
|
30
|
+
.finally(() => { if (active) setLoadedOnce(true) })
|
|
31
|
+
return () => { active = false }
|
|
78
32
|
}, [typeFilter, search])
|
|
79
33
|
|
|
80
|
-
const
|
|
34
|
+
const entity = useMemo(() => buildProductEntity(t, productTypes, currency), [t, productTypes, currency])
|
|
35
|
+
const facets = useMemo(() => [{
|
|
36
|
+
field: 'productType',
|
|
37
|
+
allLabel: t('crud.list.allFacet'),
|
|
38
|
+
options: productTypes.map((p) => ({ value: p.value, label: p.label })),
|
|
39
|
+
}], [productTypes, t])
|
|
81
40
|
|
|
82
41
|
return (
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
newLabel={t('inventory.productList.newProduct')}
|
|
98
|
-
onNew={onNew}
|
|
99
|
-
onRowClick={(row) => onEdit?.(row.id)}
|
|
100
|
-
emptyIcon={Package}
|
|
101
|
-
emptyMessage={t('inventory.productList.empty')}
|
|
102
|
-
emptyActionLabel={onNew ? t('inventory.productList.createFirst') : undefined}
|
|
103
|
-
onEmptyAction={onNew}
|
|
104
|
-
/>
|
|
105
|
-
</div>
|
|
42
|
+
<CrudListView
|
|
43
|
+
entityDef={entity}
|
|
44
|
+
items={loadedOnce ? products : null}
|
|
45
|
+
total={productsTotal}
|
|
46
|
+
search={search}
|
|
47
|
+
onSearchChange={setSearch}
|
|
48
|
+
searchPlaceholder={t('inventory.productList.searchPlaceholder')}
|
|
49
|
+
facets={facets}
|
|
50
|
+
activeFilters={{ productType: typeFilter }}
|
|
51
|
+
onFacetChange={(_field, value) => setTypeFilter(value)}
|
|
52
|
+
onNew={onNew}
|
|
53
|
+
addLabel={t('inventory.productList.newProduct')}
|
|
54
|
+
onRowClick={(row) => onEdit?.((row as { id: string }).id)}
|
|
55
|
+
/>
|
|
106
56
|
)
|
|
107
57
|
}
|
|
@@ -151,6 +151,24 @@ export function StockMovementView({ defaultType, onSaved, viewMovement }: {
|
|
|
151
151
|
|
|
152
152
|
useEffect(() => { fetchProducts({}); fetchLocations() }, [])
|
|
153
153
|
|
|
154
|
+
// Build a map of locationId → total quantity from positions.
|
|
155
|
+
// NOTE: these useMemo hooks must run before the early returns below, otherwise
|
|
156
|
+
// saving (which flips savedMovement) renders fewer hooks → React error.
|
|
157
|
+
const positionByLocation = useMemo(() => {
|
|
158
|
+
const map: Record<string, number> = {}
|
|
159
|
+
for (const pos of positions) {
|
|
160
|
+
if (pos.stockLocationId) {
|
|
161
|
+
map[pos.stockLocationId] = (map[pos.stockLocationId] ?? 0) + pos.quantity
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return map
|
|
165
|
+
}, [positions])
|
|
166
|
+
|
|
167
|
+
// Sort locations by stock position (highest first)
|
|
168
|
+
const sortedLocations = useMemo(() => {
|
|
169
|
+
return [...locations].sort((a, b) => (positionByLocation[b.id] ?? 0) - (positionByLocation[a.id] ?? 0))
|
|
170
|
+
}, [locations, positionByLocation])
|
|
171
|
+
|
|
154
172
|
// If viewing a movement detail
|
|
155
173
|
if (viewMovement) {
|
|
156
174
|
return <MovementDetail movement={viewMovement} currency={currency} onClose={() => onSaved?.()} />
|
|
@@ -198,22 +216,6 @@ export function StockMovementView({ defaultType, onSaved, viewMovement }: {
|
|
|
198
216
|
}
|
|
199
217
|
}
|
|
200
218
|
|
|
201
|
-
// Build a map of locationId → total quantity from positions
|
|
202
|
-
const positionByLocation = useMemo(() => {
|
|
203
|
-
const map: Record<string, number> = {}
|
|
204
|
-
for (const pos of positions) {
|
|
205
|
-
if (pos.stockLocationId) {
|
|
206
|
-
map[pos.stockLocationId] = (map[pos.stockLocationId] ?? 0) + pos.quantity
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
return map
|
|
210
|
-
}, [positions])
|
|
211
|
-
|
|
212
|
-
// Sort locations by stock position (highest first)
|
|
213
|
-
const sortedLocations = useMemo(() => {
|
|
214
|
-
return [...locations].sort((a, b) => (positionByLocation[b.id] ?? 0) - (positionByLocation[a.id] ?? 0))
|
|
215
|
-
}, [locations, positionByLocation])
|
|
216
|
-
|
|
217
219
|
const canProceedStep1 = !!productId
|
|
218
220
|
const canProceedStep2 = quantity > 0 && (!needsReason || reason.trim()) && (!needsDest || destLocationId)
|
|
219
221
|
const title = defaultType === 'entry' ? t('inventory.stock.entry') : defaultType === 'exit' ? t('inventory.stock.exit') : t('inventory.stock.movement')
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"ProductFormView.d.ts","sourceRoot":"","sources":["../../src/views/ProductFormView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA8B,MAAM,OAAO,CAAA;AA2ClD,wBAAgB,eAAe,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;CAAE,qBA+O7F"}
|
|
@@ -1,283 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect } from 'react'
|
|
2
|
-
import { ImagePlus } from 'lucide-react'
|
|
3
|
-
import { useInventoryConfig, useInventoryStore, useInventoryProvider, formatCurrency } from '../InventoryContext'
|
|
4
|
-
import { SubpageHeader, useSaveBar, toast } from '@fayz-ai/ui'
|
|
5
|
-
import { CurrencyInput } from '@fayz-ai/ui'
|
|
6
|
-
import { useTranslation } from '@fayz-ai/core'
|
|
7
|
-
import type { ProductType } from '../types'
|
|
8
|
-
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
// Product type descriptions — parametrizable via config
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
|
|
13
|
-
const TYPE_DESCRIPTION_KEYS: Record<string, string> = {
|
|
14
|
-
ingredient: 'inventory.productForm.typeIngredient',
|
|
15
|
-
sale: 'inventory.productForm.typeSale',
|
|
16
|
-
intermediate: 'inventory.productForm.typeIntermediate',
|
|
17
|
-
asset: 'inventory.productForm.typeAsset',
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
|
-
// Section component for grouping form fields
|
|
22
|
-
// ---------------------------------------------------------------------------
|
|
23
|
-
|
|
24
|
-
function FormSection({ title, subtitle, children }: {
|
|
25
|
-
title: string; subtitle?: string; children: React.ReactNode
|
|
26
|
-
}) {
|
|
27
|
-
return (
|
|
28
|
-
<div className="rounded-lg border bg-card shadow-sm">
|
|
29
|
-
<div className="px-5 py-3 border-b">
|
|
30
|
-
<h3 className="text-sm font-semibold">{title}</h3>
|
|
31
|
-
{subtitle && <p className="text-[10px] text-muted-foreground mt-0.5">{subtitle}</p>}
|
|
32
|
-
</div>
|
|
33
|
-
<div className="p-5 space-y-4">
|
|
34
|
-
{children}
|
|
35
|
-
</div>
|
|
36
|
-
</div>
|
|
37
|
-
)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
// Product form
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
|
|
44
|
-
export function ProductFormView({ editId, onSaved }: { editId?: string; onSaved?: () => void }) {
|
|
45
|
-
const t = useTranslation()
|
|
46
|
-
const { productTypes, currency } = useInventoryConfig()
|
|
47
|
-
const provider = useInventoryProvider()
|
|
48
|
-
const createProduct = useInventoryStore((s) => s.createProduct)
|
|
49
|
-
const isEdit = !!editId
|
|
50
|
-
|
|
51
|
-
const [loaded, setLoaded] = useState(false)
|
|
52
|
-
const [name, setName] = useState('')
|
|
53
|
-
const [sku, setSku] = useState('')
|
|
54
|
-
const [barcode, setBarcode] = useState('')
|
|
55
|
-
const [brand, setBrand] = useState('')
|
|
56
|
-
const [productType, setProductType] = useState<ProductType>(productTypes[0]?.value as ProductType ?? 'ingredient')
|
|
57
|
-
const [costPrice, setCostPrice] = useState(0)
|
|
58
|
-
const [salePrice, setSalePrice] = useState(0)
|
|
59
|
-
const [minQuantity, setMinQuantity] = useState(0)
|
|
60
|
-
const [maxQuantity, setMaxQuantity] = useState(0)
|
|
61
|
-
const [description, setDescription] = useState('')
|
|
62
|
-
const [saving, setSaving] = useState(false)
|
|
63
|
-
|
|
64
|
-
// Load existing product for edit
|
|
65
|
-
useEffect(() => {
|
|
66
|
-
if (!editId || loaded) return
|
|
67
|
-
;(async () => {
|
|
68
|
-
const product = await provider.getProductById(editId)
|
|
69
|
-
if (!product) return
|
|
70
|
-
setName(product.name)
|
|
71
|
-
setSku(product.sku ?? '')
|
|
72
|
-
setBarcode(product.barcode ?? '')
|
|
73
|
-
setBrand(product.brand ?? '')
|
|
74
|
-
setProductType(product.productType)
|
|
75
|
-
setCostPrice(product.costPrice)
|
|
76
|
-
setSalePrice(product.salePrice ?? 0)
|
|
77
|
-
setMinQuantity(product.minQuantity)
|
|
78
|
-
setMaxQuantity(product.maxQuantity ?? 0)
|
|
79
|
-
setDescription(product.description ?? '')
|
|
80
|
-
setLoaded(true)
|
|
81
|
-
})()
|
|
82
|
-
}, [editId])
|
|
83
|
-
|
|
84
|
-
const fields = { name, sku, barcode, brand, productType, costPrice, salePrice, minQuantity, maxQuantity, description }
|
|
85
|
-
const ready = !editId || loaded
|
|
86
|
-
const snapshot = React.useRef<string | null>(null)
|
|
87
|
-
useEffect(() => {
|
|
88
|
-
if (ready && snapshot.current === null) snapshot.current = JSON.stringify(fields)
|
|
89
|
-
}, [ready])
|
|
90
|
-
const dirty = snapshot.current !== null && JSON.stringify(fields) !== snapshot.current
|
|
91
|
-
useSaveBar({
|
|
92
|
-
dirty,
|
|
93
|
-
saving,
|
|
94
|
-
onSave: () => { void handleSave() },
|
|
95
|
-
onDiscard: () => onSaved?.(),
|
|
96
|
-
saveLabel: t('inventory.productForm.save'),
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
async function handleSave() {
|
|
100
|
-
if (!name.trim()) { toast.error(t('common.formIncomplete')); return }
|
|
101
|
-
setSaving(true)
|
|
102
|
-
try {
|
|
103
|
-
if (isEdit && editId) {
|
|
104
|
-
await provider.updateProduct(editId, {
|
|
105
|
-
name, sku: sku || undefined, barcode: barcode || undefined, brand: brand || undefined,
|
|
106
|
-
productType, costPrice, salePrice: salePrice || undefined,
|
|
107
|
-
minQuantity, maxQuantity: maxQuantity || undefined, description: description || undefined,
|
|
108
|
-
})
|
|
109
|
-
} else {
|
|
110
|
-
await createProduct({
|
|
111
|
-
name, sku: sku || undefined, barcode: barcode || undefined, brand: brand || undefined,
|
|
112
|
-
productType, costPrice, salePrice: salePrice || undefined,
|
|
113
|
-
minQuantity, maxQuantity: maxQuantity || undefined, description: description || undefined,
|
|
114
|
-
})
|
|
115
|
-
}
|
|
116
|
-
onSaved?.()
|
|
117
|
-
} finally {
|
|
118
|
-
setSaving(false)
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const title = isEdit ? (name || t('inventory.productForm.editProduct')) : t('inventory.productForm.newProduct')
|
|
123
|
-
const subtitle = isEdit ? undefined : t('inventory.productForm.addToCatalog')
|
|
124
|
-
const margin = salePrice > 0 && costPrice > 0 ? ((salePrice - costPrice) / costPrice * 100).toFixed(1) : null
|
|
125
|
-
|
|
126
|
-
return (
|
|
127
|
-
<div className="space-y-5">
|
|
128
|
-
<SubpageHeader
|
|
129
|
-
title={title}
|
|
130
|
-
subtitle={subtitle}
|
|
131
|
-
onBack={onSaved}
|
|
132
|
-
parentLabel={t('inventory.nav.products')}
|
|
133
|
-
/>
|
|
134
|
-
|
|
135
|
-
{/* Section 1: General Info */}
|
|
136
|
-
<FormSection title={t('inventory.productForm.generalInfo')}>
|
|
137
|
-
<div className="flex gap-4">
|
|
138
|
-
{/* Image placeholder */}
|
|
139
|
-
<div className="shrink-0">
|
|
140
|
-
<div className="w-20 h-20 rounded-lg border-2 border-dashed border-muted flex items-center justify-center text-muted-foreground hover:border-primary/30 hover:text-primary/50 transition-colors cursor-pointer">
|
|
141
|
-
<ImagePlus className="h-5 w-5" />
|
|
142
|
-
</div>
|
|
143
|
-
</div>
|
|
144
|
-
|
|
145
|
-
<div className="flex-1 grid gap-3 sm:grid-cols-2">
|
|
146
|
-
<div className="sm:col-span-1">
|
|
147
|
-
<label className="text-xs font-medium text-muted-foreground">{t('inventory.productForm.name')} *</label>
|
|
148
|
-
<input
|
|
149
|
-
type="text"
|
|
150
|
-
value={name}
|
|
151
|
-
onChange={(e) => setName(e.target.value)}
|
|
152
|
-
placeholder={t('inventory.productForm.namePlaceholder')}
|
|
153
|
-
autoFocus
|
|
154
|
-
className="w-full mt-1 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
155
|
-
/>
|
|
156
|
-
</div>
|
|
157
|
-
<div>
|
|
158
|
-
<label className="text-xs font-medium text-muted-foreground">{t('inventory.productForm.brand')}</label>
|
|
159
|
-
<input
|
|
160
|
-
type="text"
|
|
161
|
-
value={brand}
|
|
162
|
-
onChange={(e) => setBrand(e.target.value)}
|
|
163
|
-
placeholder={t('inventory.productForm.brandPlaceholder')}
|
|
164
|
-
className="w-full mt-1 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
165
|
-
/>
|
|
166
|
-
</div>
|
|
167
|
-
</div>
|
|
168
|
-
</div>
|
|
169
|
-
|
|
170
|
-
<div className="grid gap-3 sm:grid-cols-2">
|
|
171
|
-
<div>
|
|
172
|
-
<label className="text-xs font-medium text-muted-foreground">{t('inventory.productForm.sku')}</label>
|
|
173
|
-
<input type="text" value={sku} onChange={(e) => setSku(e.target.value)} placeholder={t('inventory.productForm.skuPlaceholder')} className="w-full mt-1 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
|
|
174
|
-
</div>
|
|
175
|
-
<div>
|
|
176
|
-
<label className="text-xs font-medium text-muted-foreground">{t('inventory.productForm.barcode')}</label>
|
|
177
|
-
<input type="text" value={barcode} onChange={(e) => setBarcode(e.target.value)} placeholder={t('inventory.productForm.barcodePlaceholder')} className="w-full mt-1 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
|
|
178
|
-
</div>
|
|
179
|
-
</div>
|
|
180
|
-
|
|
181
|
-
<div>
|
|
182
|
-
<label className="text-xs font-medium text-muted-foreground">{t('inventory.productForm.description')}</label>
|
|
183
|
-
<textarea
|
|
184
|
-
value={description}
|
|
185
|
-
onChange={(e) => setDescription(e.target.value)}
|
|
186
|
-
rows={2}
|
|
187
|
-
placeholder={t('inventory.productForm.descriptionPlaceholder')}
|
|
188
|
-
className="w-full mt-1 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
|
|
189
|
-
/>
|
|
190
|
-
</div>
|
|
191
|
-
</FormSection>
|
|
192
|
-
|
|
193
|
-
{/* Section 2: Classification */}
|
|
194
|
-
<FormSection title={t('inventory.productForm.classification')} subtitle={t('inventory.productForm.classificationDesc')}>
|
|
195
|
-
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
|
196
|
-
{productTypes.map((pt) => {
|
|
197
|
-
const active = productType === pt.value
|
|
198
|
-
const descKey = TYPE_DESCRIPTION_KEYS[pt.value]
|
|
199
|
-
const desc = descKey ? t(descKey) : ''
|
|
200
|
-
return (
|
|
201
|
-
<button
|
|
202
|
-
key={pt.value}
|
|
203
|
-
onClick={() => setProductType(pt.value as ProductType)}
|
|
204
|
-
className={`rounded-lg border-2 p-3 text-left transition-all ${
|
|
205
|
-
active
|
|
206
|
-
? 'border-primary bg-primary/5'
|
|
207
|
-
: 'border-transparent bg-muted/20 hover:bg-muted/40'
|
|
208
|
-
}`}
|
|
209
|
-
>
|
|
210
|
-
<p className={`text-sm font-medium ${active ? 'text-primary' : ''}`}>{pt.label}</p>
|
|
211
|
-
{desc && <p className="text-[10px] text-muted-foreground mt-0.5 leading-tight">{desc}</p>}
|
|
212
|
-
</button>
|
|
213
|
-
)
|
|
214
|
-
})}
|
|
215
|
-
</div>
|
|
216
|
-
</FormSection>
|
|
217
|
-
|
|
218
|
-
{/* Section 3: Pricing */}
|
|
219
|
-
<FormSection title={t('inventory.productForm.pricing')}>
|
|
220
|
-
<div className="grid gap-4 sm:grid-cols-3">
|
|
221
|
-
<CurrencyInput
|
|
222
|
-
label={t('inventory.productForm.costPrice')}
|
|
223
|
-
value={costPrice}
|
|
224
|
-
onChange={setCostPrice}
|
|
225
|
-
symbol={currency.symbol}
|
|
226
|
-
locale={currency.locale}
|
|
227
|
-
currencyCode={currency.code}
|
|
228
|
-
/>
|
|
229
|
-
<CurrencyInput
|
|
230
|
-
label={t('inventory.productForm.salePrice')}
|
|
231
|
-
value={salePrice}
|
|
232
|
-
onChange={setSalePrice}
|
|
233
|
-
symbol={currency.symbol}
|
|
234
|
-
locale={currency.locale}
|
|
235
|
-
currencyCode={currency.code}
|
|
236
|
-
/>
|
|
237
|
-
<div>
|
|
238
|
-
<label className="text-xs font-medium text-muted-foreground">{t('inventory.productForm.margin')}</label>
|
|
239
|
-
<div className="mt-1 rounded-lg border bg-muted/20 px-3 py-2 text-sm tabular-nums text-right">
|
|
240
|
-
{margin !== null ? (
|
|
241
|
-
<span className={Number(margin) >= 0 ? 'text-success' : 'text-destructive'}>
|
|
242
|
-
{margin}%
|
|
243
|
-
</span>
|
|
244
|
-
) : (
|
|
245
|
-
<span className="text-muted-foreground">—</span>
|
|
246
|
-
)}
|
|
247
|
-
</div>
|
|
248
|
-
</div>
|
|
249
|
-
</div>
|
|
250
|
-
</FormSection>
|
|
251
|
-
|
|
252
|
-
{/* Section 4: Stock Levels */}
|
|
253
|
-
<FormSection title={t('inventory.productForm.stockLevels')} subtitle={t('inventory.productForm.stockLevelsDesc')}>
|
|
254
|
-
<div className="grid gap-4 sm:grid-cols-2 max-w-md">
|
|
255
|
-
<div>
|
|
256
|
-
<label className="text-xs font-medium text-muted-foreground">{t('inventory.productForm.minQuantity')}</label>
|
|
257
|
-
<input
|
|
258
|
-
type="number"
|
|
259
|
-
min={0}
|
|
260
|
-
value={minQuantity}
|
|
261
|
-
onChange={(e) => setMinQuantity(Number(e.target.value) || 0)}
|
|
262
|
-
placeholder="0"
|
|
263
|
-
className="w-full mt-1 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
264
|
-
/>
|
|
265
|
-
<p className="text-[10px] text-muted-foreground mt-1">{t('inventory.productForm.minQuantityHint')}</p>
|
|
266
|
-
</div>
|
|
267
|
-
<div>
|
|
268
|
-
<label className="text-xs font-medium text-muted-foreground">{t('inventory.productForm.maxQuantity')}</label>
|
|
269
|
-
<input
|
|
270
|
-
type="number"
|
|
271
|
-
min={0}
|
|
272
|
-
value={maxQuantity}
|
|
273
|
-
onChange={(e) => setMaxQuantity(Number(e.target.value) || 0)}
|
|
274
|
-
placeholder={t('inventory.productForm.optional')}
|
|
275
|
-
className="w-full mt-1 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
276
|
-
/>
|
|
277
|
-
<p className="text-[10px] text-muted-foreground mt-1">{t('inventory.productForm.maxQuantityHint')}</p>
|
|
278
|
-
</div>
|
|
279
|
-
</div>
|
|
280
|
-
</FormSection>
|
|
281
|
-
</div>
|
|
282
|
-
)
|
|
283
|
-
}
|