@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.
@@ -1 +1 @@
1
- {"version":3,"file":"ProductListView.d.ts","sourceRoot":"","sources":["../../src/views/ProductListView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA8B,MAAM,OAAO,CAAA;AA6DlD,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
+ {"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,qBAuYA"}
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.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.1.6",
30
- "@fayz-ai/saas": "^0.1.6",
31
- "@fayz-ai/ui": "^0.1.6"
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",
@@ -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,6 +0,0 @@
1
- import React from 'react';
2
- export declare function ProductFormView({ editId, onSaved }: {
3
- editId?: string;
4
- onSaved?: () => void;
5
- }): React.JSX.Element;
6
- //# sourceMappingURL=ProductFormView.d.ts.map
@@ -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
- }