@fayz-ai/plugin-inventory 0.1.1 → 0.1.2
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/README.md +50 -0
- 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/src/views/ProductFormView.tsx +0 -283
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# @fayz-ai/plugin-inventory
|
|
2
|
+
|
|
3
|
+
> Stock that actually ties to your business — products, movements, recipes, and low-stock alerts.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@fayz-ai/plugin-inventory)
|
|
6
|
+
[](https://github.com/FayaLabs/fayz-sdk/blob/main/LICENSE)
|
|
7
|
+
|
|
8
|
+
Inventory is the universal organ — a salon counts product, a restaurant counts ingredients, a clinic counts supplies. `plugin-inventory` is that organ as a plugin: a product catalog, stock entry/exit/history, optional recipes (bill of materials), and a dashboard that surfaces stock value and what's running low. It's `scope: universal`, so it snaps into any vertical.
|
|
9
|
+
|
|
10
|
+
It ships real data out of the box: a Supabase provider with an automatic mock fallback, so a fresh app demos immediately and a configured one persists for real. Recipes, stock locations, and batch tracking are opt-in modules; product types, currency, and labels are configurable. The host dashboard gets inventory KPI widgets, and the AI assistant can answer "what's running low?" The intent: stop rebuilding stock control per app — compose it from one plugin into a real SaaS.
|
|
11
|
+
|
|
12
|
+
## What's inside
|
|
13
|
+
- **Navigation + page** — an `/inventory` surface: product catalog, stock entry/exit, movement history, and recipes
|
|
14
|
+
- **Real data, zero setup** — a Supabase provider that safely falls back to a mock provider, or inject your own `InventoryDataProvider`
|
|
15
|
+
- **Dashboard widgets** — stock-value KPI (headline on the home), plus total-products, low-stock, out-of-stock KPIs and a recent-activity panel
|
|
16
|
+
- **Opt-in modules** — `recipes` and `stockLocations` (on by default), `batchTracking` (off by default)
|
|
17
|
+
- **AI tool** — `getLowStock` (products below minimum threshold), with vertical-aware prompt suggestions
|
|
18
|
+
- **Declared features, settings + i18n** — registers `inventory` (and `inventory.recipes`) features, a general settings tab, and bundled locales
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
```bash
|
|
22
|
+
npm install @fayz-ai/plugin-inventory
|
|
23
|
+
```
|
|
24
|
+
Peer deps: `@fayz-ai/core`, `@fayz-ai/ui`, `@fayz-ai/saas` (+ react, react-dom).
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
```tsx
|
|
28
|
+
import { defineSaas } from '@fayz-ai/saas'
|
|
29
|
+
import { createInventoryPlugin } from '@fayz-ai/plugin-inventory'
|
|
30
|
+
|
|
31
|
+
export const app = defineSaas({
|
|
32
|
+
// ...
|
|
33
|
+
plugins: [
|
|
34
|
+
createInventoryPlugin({
|
|
35
|
+
modules: { recipes: true, stockLocations: true, batchTracking: false },
|
|
36
|
+
currency: { code: 'BRL', locale: 'pt-BR', symbol: 'R$' },
|
|
37
|
+
locations: [{ id: 'hq', name: 'Main', isHQ: true }],
|
|
38
|
+
// dataProvider: myProvider, // optional — defaults to Supabase + mock fallback
|
|
39
|
+
}),
|
|
40
|
+
],
|
|
41
|
+
})
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Options: `modules`, `labels`, `productTypes`, `currency`, `navPosition`, `navSection`, `scope`, `verticalId`, `dataProvider`, `locations`.
|
|
45
|
+
|
|
46
|
+
## Part of the Fayz SDK
|
|
47
|
+
A composable, universal plugin for the Fayz SDK. Underpins `@fayz-ai/plugin-menu` recipes and `@fayz-ai/plugin-shop` stock — pair them for a full order-to-cash picture.
|
|
48
|
+
|
|
49
|
+
## Roadmap & contributing
|
|
50
|
+
Built and evolving in the open. See the [Fayz SDK roadmap](../../docs/ROADMAP.md#plugin-inventory) for current gaps, missing features, and good first issues.
|
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.2",
|
|
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/
|
|
30
|
-
"@fayz-ai/
|
|
31
|
-
"@fayz-ai/
|
|
29
|
+
"@fayz-ai/ui": "^0.1.7",
|
|
30
|
+
"@fayz-ai/core": "^0.1.7",
|
|
31
|
+
"@fayz-ai/saas": "^0.1.7"
|
|
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,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
|
-
}
|