@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 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
+ [![npm](https://img.shields.io/npm/v/@fayz-ai/plugin-inventory.svg)](https://www.npmjs.com/package/@fayz-ai/plugin-inventory)
6
+ [![license](https://img.shields.io/npm/l/@fayz-ai/plugin-inventory.svg)](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.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/core": "^0.1.6",
30
- "@fayz-ai/saas": "^0.1.6",
31
- "@fayz-ai/ui": "^0.1.6"
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",
@@ -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 { ProductFormView } from './views/ProductFormView'
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: () => <ProductFormView onSaved={() => navigate('products-list')} /> },
133
- { id: 'products-edit', render: ({ id }) => <ProductFormView editId={id!} onSaved={() => navigate('products-list')} /> },
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}`)} /> },
@@ -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 { Package } from 'lucide-react'
3
- import type { ColumnDef } from '@tanstack/react-table'
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, Product } from '../types'
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
- fetchProducts({ productType: typeFilter as ProductType | undefined, search: search || undefined })
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 columns = useColumns(currency, t)
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
- <div className="space-y-4">
84
- <SubpageHeader title={t('inventory.productList.title')} subtitle={t('inventory.productList.subtitle', { count: String(productsTotal) })} />
85
-
86
- <ListView<Product>
87
- columns={columns}
88
- data={products}
89
- loading={productsLoading}
90
- searchPlaceholder={t('inventory.productList.searchPlaceholder')}
91
- search={search}
92
- onSearchChange={setSearch}
93
- searchDebounce={0}
94
- tags={productTypes.map((t) => ({ value: t.value, label: t.label }))}
95
- activeTag={typeFilter}
96
- onTagChange={setTypeFilter}
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
- }