@fayz-ai/plugin-inventory 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +3 -3
  2. package/dist/{InventoryContext.d.ts → context.d.ts} +2 -2
  3. package/dist/context.d.ts.map +1 -0
  4. package/dist/data/registries.d.ts.map +1 -0
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +7 -3
  8. package/dist/index.js.map +1 -1
  9. package/dist/{InventoryPage.d.ts → views/InventoryPage.d.ts} +3 -3
  10. package/dist/views/InventoryPage.d.ts.map +1 -0
  11. package/dist/views/ProductCrudForm.d.ts.map +1 -1
  12. package/dist/views/dashboardWidgets.d.ts +1 -1
  13. package/dist/views/dashboardWidgets.d.ts.map +1 -1
  14. package/dist/views/productEntity.d.ts +1 -1
  15. package/dist/views/productEntity.d.ts.map +1 -1
  16. package/package.json +5 -7
  17. package/dist/InventoryContext.d.ts.map +0 -1
  18. package/dist/InventoryPage.d.ts.map +0 -1
  19. package/dist/registries.d.ts.map +0 -1
  20. package/src/InventoryContext.tsx +0 -42
  21. package/src/InventoryPage.tsx +0 -176
  22. package/src/README.md +0 -177
  23. package/src/components/InventoryGeneralSettings.tsx +0 -39
  24. package/src/components/InventoryOnboarding.tsx +0 -60
  25. package/src/components/InventorySettings.tsx +0 -27
  26. package/src/data/index.ts +0 -2
  27. package/src/data/mock.ts +0 -266
  28. package/src/data/supabase.ts +0 -361
  29. package/src/data/tables.ts +0 -10
  30. package/src/data/types.ts +0 -35
  31. package/src/index.ts +0 -238
  32. package/src/lib/tenant.ts +0 -4
  33. package/src/locales/en.ts +0 -242
  34. package/src/locales/index.ts +0 -7
  35. package/src/locales/pt-BR.ts +0 -242
  36. package/src/migrations/000_plg_rename.sql +0 -27
  37. package/src/migrations/001_inventory_base.sql +0 -69
  38. package/src/migrations/002_recipes.sql +0 -34
  39. package/src/migrations/003_measurement_units.sql +0 -13
  40. package/src/migrations/index.ts +0 -161
  41. package/src/registries.ts +0 -111
  42. package/src/store.ts +0 -127
  43. package/src/types.ts +0 -256
  44. package/src/views/DashboardView.tsx +0 -11
  45. package/src/views/MovementHistoryView.tsx +0 -104
  46. package/src/views/ProductCrudForm.tsx +0 -104
  47. package/src/views/ProductListView.tsx +0 -58
  48. package/src/views/RecipeDetailView.tsx +0 -192
  49. package/src/views/RecipeFormView.tsx +0 -241
  50. package/src/views/RecipesView.tsx +0 -106
  51. package/src/views/StockMovementView.tsx +0 -518
  52. package/src/views/dashboardWidgets.tsx +0 -101
  53. package/src/views/productEntity.tsx +0 -124
  54. /package/dist/{registries.d.ts → data/registries.d.ts} +0 -0
@@ -1,104 +0,0 @@
1
- import React from 'react'
2
- import { SubpageHeader, toast } from '@fayz-ai/ui'
3
- import { useTranslation } from '@fayz-ai/core'
4
- import { CrudFormPage, useLimitGuard, invalidateLimit } from '@fayz-ai/saas'
5
- import { useInventoryConfig, useInventoryStore, useInventoryProvider } from '../InventoryContext'
6
- import type { ProductType } from '../types'
7
- import { buildProductEntity } from './productEntity'
8
-
9
- // ---------------------------------------------------------------------------
10
- // Product create/edit, rendered through the generic CRUD form. The sectioned
11
- // layout (General Information · Classification · Pricing+Margin · Stock Levels)
12
- // comes from the shared buildProductEntity (segmented/computed/currency field
13
- // types). Persistence still flows through the inventory provider/store, so the
14
- // metadata-JSON column mapping is unchanged.
15
- // ---------------------------------------------------------------------------
16
-
17
- export function ProductCrudForm({ editId, onSaved }: { editId?: string; onSaved?: () => void }) {
18
- const t = useTranslation()
19
- const { productTypes, currency } = useInventoryConfig()
20
- const provider = useInventoryProvider()
21
- const createProduct = useInventoryStore((s) => s.createProduct)
22
- const guardProducts = useLimitGuard('products')
23
- const isEdit = !!editId
24
-
25
- const [initialData, setInitialData] = React.useState<Record<string, any> | null>(isEdit ? null : {})
26
- const [headerName, setHeaderName] = React.useState('')
27
-
28
- // Load the existing product for edit and map it to flat form values.
29
- React.useEffect(() => {
30
- if (!editId) return
31
- let cancelled = false
32
- ;(async () => {
33
- const p = await provider.getProductById(editId)
34
- if (cancelled) return
35
- setHeaderName(p?.name ?? '')
36
- setInitialData(
37
- p
38
- ? {
39
- name: p.name,
40
- brand: p.brand ?? '',
41
- sku: p.sku ?? '',
42
- barcode: p.barcode ?? '',
43
- description: p.description ?? '',
44
- productType: p.productType,
45
- costPrice: p.costPrice,
46
- salePrice: p.salePrice ?? 0,
47
- minQuantity: p.minQuantity,
48
- maxQuantity: p.maxQuantity ?? 0,
49
- }
50
- : {},
51
- )
52
- })()
53
- return () => { cancelled = true }
54
- }, [editId])
55
-
56
- const entity = React.useMemo(() => buildProductEntity(t, productTypes, currency), [t, productTypes, currency])
57
-
58
- async function save(values: Record<string, any>) {
59
- const name = String(values.name ?? '').trim()
60
- if (!name) { toast.error(t('common.formIncomplete')); throw new Error('name required') }
61
- const payload = {
62
- name,
63
- sku: values.sku || undefined,
64
- barcode: values.barcode || undefined,
65
- brand: values.brand || undefined,
66
- productType: (values.productType ?? productTypes[0]?.value) as ProductType,
67
- costPrice: Number(values.costPrice) || 0,
68
- salePrice: Number(values.salePrice) || undefined,
69
- minQuantity: Number(values.minQuantity) || 0,
70
- maxQuantity: Number(values.maxQuantity) || undefined,
71
- description: values.description || undefined,
72
- }
73
- if (isEdit && editId) {
74
- await provider.updateProduct(editId, payload)
75
- } else {
76
- // Plan quantity guard (client-side, before the provider call). Opens the
77
- // global UpgradeModal and aborts when the plan's product cap is reached.
78
- if ((await guardProducts()) === 'blocked') return
79
- await createProduct(payload)
80
- invalidateLimit('products')
81
- }
82
- onSaved?.()
83
- }
84
-
85
- const title = isEdit ? (headerName || t('inventory.productForm.editProduct')) : t('inventory.productForm.newProduct')
86
- const subtitle = isEdit ? undefined : t('inventory.productForm.addToCatalog')
87
-
88
- return (
89
- <div className="space-y-5">
90
- <SubpageHeader title={title} subtitle={subtitle} onBack={onSaved} parentLabel={t('inventory.nav.products')} />
91
- {initialData && (
92
- <CrudFormPage
93
- entityDef={entity}
94
- mode={isEdit ? 'edit' : 'create'}
95
- initialData={initialData}
96
- onSubmit={save}
97
- onCancel={() => onSaved?.()}
98
- namePlural={t('inventory.nav.products')}
99
- hideBreadcrumb
100
- />
101
- )}
102
- </div>
103
- )
104
- }
@@ -1,58 +0,0 @@
1
- import React, { useEffect, useMemo, useState } from 'react'
2
- import { CrudListView } from '@fayz-ai/saas'
3
- import { useInventoryConfig, useInventoryStore } from '../InventoryContext'
4
- import { useTranslation } from '@fayz-ai/core'
5
- import type { ProductType } from '../types'
6
- import { buildProductEntity } from './productEntity'
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.
12
- export function ProductListView({ onNew, onEdit }: {
13
- onNew?: () => void
14
- onEdit?: (id: string) => void
15
- }) {
16
- const t = useTranslation()
17
- const { currency, productTypes } = useInventoryConfig()
18
- const products = useInventoryStore((s) => s.products)
19
- const productsTotal = useInventoryStore((s) => s.productsTotal)
20
- const productsLoading = useInventoryStore((s) => s.productsLoading)
21
- const fetchProducts = useInventoryStore((s) => s.fetchProducts)
22
-
23
- const [search, setSearch] = useState('')
24
- const [typeFilter, setTypeFilter] = useState<string | undefined>()
25
- const [loadedOnce, setLoadedOnce] = useState(false)
26
-
27
- useEffect(() => {
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 }
32
- }, [typeFilter, search])
33
-
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])
40
-
41
- return (
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
- feature="inventory"
56
- />
57
- )
58
- }
@@ -1,192 +0,0 @@
1
- import React, { useEffect, useState } from 'react'
2
- import { BookOpen, Clock, Layers, Package } from 'lucide-react'
3
- import type { ColumnDef } from '@tanstack/react-table'
4
- import { useInventoryProvider } from '../InventoryContext'
5
- import { useTranslation } from '@fayz-ai/core'
6
- import { Breadcrumb, DataTable } from '@fayz-ai/ui'
7
- import type { Recipe, RecipeIngredient } from '../types'
8
-
9
- function DetailSkeleton() {
10
- return (
11
- <div className="space-y-5">
12
- <div className="flex items-center gap-3">
13
- <div className="h-12 w-12 rounded-xl bg-muted/40 animate-pulse" />
14
- <div className="space-y-2 flex-1">
15
- <div className="h-5 w-40 rounded bg-muted/40 animate-pulse" />
16
- <div className="h-3 w-24 rounded bg-muted/30 animate-pulse" />
17
- </div>
18
- </div>
19
- <div className="rounded-xl border divide-y">
20
- {[1, 2, 3].map((i) => (
21
- <div key={i} className="flex items-center gap-3 px-4 py-3">
22
- <div className="h-3 w-20 rounded bg-muted/30 animate-pulse" />
23
- <div className="flex-1" />
24
- <div className="h-3 w-16 rounded bg-muted/40 animate-pulse" />
25
- </div>
26
- ))}
27
- </div>
28
- <div className="rounded-xl border p-4 space-y-2">
29
- {[1, 2, 3, 4].map((i) => (
30
- <div key={i} className="flex items-center gap-3">
31
- <div className="h-3.5 w-6 rounded bg-muted/30 animate-pulse" />
32
- <div className="h-3.5 flex-1 rounded bg-muted/30 animate-pulse" />
33
- <div className="h-3.5 w-12 rounded bg-muted/40 animate-pulse" />
34
- </div>
35
- ))}
36
- </div>
37
- </div>
38
- )
39
- }
40
-
41
- export function RecipeDetailView({ recipeId, onBack }: { recipeId: string; onBack: () => void }) {
42
- const t = useTranslation()
43
- const provider = useInventoryProvider()
44
- const [recipe, setRecipe] = useState<Recipe | null>(null)
45
- const [ingredients, setIngredients] = useState<RecipeIngredient[]>([])
46
- const [loading, setLoading] = useState(true)
47
-
48
- useEffect(() => {
49
- setLoading(true)
50
- Promise.all([
51
- provider.getRecipeById(recipeId),
52
- provider.getRecipeIngredients(recipeId),
53
- ]).then(([r, ings]) => {
54
- setRecipe(r)
55
- setIngredients(ings.sort((a, b) => a.displayOrder - b.displayOrder))
56
- setLoading(false)
57
- })
58
- }, [recipeId])
59
-
60
- if (loading) {
61
- return (
62
- <div className="space-y-6">
63
- <Breadcrumb parent={t('inventory.nav.recipes')} current={t('inventory.recipeDetail.loading')} onBack={onBack} />
64
- <DetailSkeleton />
65
- </div>
66
- )
67
- }
68
-
69
- if (!recipe) {
70
- return (
71
- <div className="space-y-6">
72
- <Breadcrumb parent={t('inventory.nav.recipes')} onBack={onBack} />
73
- <div className="flex flex-col items-center justify-center py-16 text-center rounded-lg border-2 border-dashed border-muted">
74
- <BookOpen className="h-8 w-8 text-muted-foreground/30 mb-2" />
75
- <p className="text-sm text-muted-foreground">{t('inventory.recipeDetail.notFound')}</p>
76
- <button onClick={onBack} className="text-xs text-primary hover:underline mt-1">{t('inventory.recipeDetail.backToList')}</button>
77
- </div>
78
- </div>
79
- )
80
- }
81
-
82
- const ingredientColumns: ColumnDef<RecipeIngredient, any>[] = [
83
- {
84
- id: 'index', header: '#',
85
- cell: ({ row }) => <span className="text-xs text-muted-foreground">{row.index + 1}</span>,
86
- },
87
- {
88
- accessorKey: 'productName', header: t('inventory.recipeDetail.ingredient'),
89
- cell: ({ getValue }) => <span className="font-medium">{(getValue() as string) || '—'}</span>,
90
- },
91
- {
92
- accessorKey: 'quantity', header: () => <span className="block text-right">{t('inventory.recipeDetail.quantity')}</span>,
93
- cell: ({ getValue }) => <span className="block text-right tabular-nums">{getValue() as number}</span>,
94
- },
95
- {
96
- accessorKey: 'unitName', header: t('inventory.recipeDetail.unit'),
97
- cell: ({ getValue }) => <span className="text-xs text-muted-foreground">{(getValue() as string) || '—'}</span>,
98
- },
99
- {
100
- accessorKey: 'notes', header: t('inventory.recipeDetail.notes'),
101
- cell: ({ getValue }) => <span className="text-xs text-muted-foreground">{(getValue() as string) || '—'}</span>,
102
- },
103
- ]
104
-
105
- return (
106
- <div className="space-y-6">
107
- {/* Breadcrumb */}
108
- <Breadcrumb parent={t('inventory.nav.recipes')} current={recipe.name} onBack={onBack} />
109
-
110
- {/* Hero */}
111
- <div className="flex items-start gap-4">
112
- <div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary">
113
- <BookOpen className="h-6 w-6" />
114
- </div>
115
- <div className="flex-1 min-w-0">
116
- <h1 className="text-2xl font-bold text-foreground">{recipe.name}</h1>
117
- {recipe.description && <p className="text-muted-foreground mt-0.5 text-sm">{recipe.description}</p>}
118
- <div className="flex items-center gap-3 mt-2">
119
- {recipe.isActive ? (
120
- <span className="inline-flex items-center gap-1 text-[10px] text-success"><span className="h-1.5 w-1.5 rounded-full bg-success" /> {t('inventory.recipeDetail.active')}</span>
121
- ) : (
122
- <span className="inline-flex items-center gap-1 text-[10px] text-muted-foreground/50"><span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/30" /> {t('inventory.recipeDetail.inactive')}</span>
123
- )}
124
- </div>
125
- </div>
126
- </div>
127
-
128
- <div className="border-t" />
129
-
130
- <div className="grid gap-4 lg:grid-cols-3">
131
- {/* Left: Info */}
132
- <div className="space-y-4">
133
- {/* Metrics */}
134
- <div className="rounded-xl border divide-y">
135
- {recipe.productName && (
136
- <div className="flex items-center gap-3 px-4 py-3">
137
- <Package className="h-4 w-4 text-muted-foreground shrink-0" />
138
- <span className="text-xs text-muted-foreground flex-1">{t('inventory.recipeDetail.produces')}</span>
139
- <span className="text-sm font-medium">{recipe.productName}</span>
140
- </div>
141
- )}
142
- <div className="flex items-center gap-3 px-4 py-3">
143
- <Layers className="h-4 w-4 text-muted-foreground shrink-0" />
144
- <span className="text-xs text-muted-foreground flex-1">{t('inventory.recipeDetail.yield')}</span>
145
- <span className="text-sm font-medium">{recipe.yieldQuantity}{recipe.yieldUnitName ? ` ${recipe.yieldUnitName}` : ''}</span>
146
- </div>
147
- {recipe.preparationTimeMinutes != null && (
148
- <div className="flex items-center gap-3 px-4 py-3">
149
- <Clock className="h-4 w-4 text-muted-foreground shrink-0" />
150
- <span className="text-xs text-muted-foreground flex-1">{t('inventory.recipeDetail.prepTime')}</span>
151
- <span className="text-sm font-medium">{recipe.preparationTimeMinutes} min</span>
152
- </div>
153
- )}
154
- <div className="flex items-center gap-3 px-4 py-3">
155
- <Layers className="h-4 w-4 text-muted-foreground shrink-0" />
156
- <span className="text-xs text-muted-foreground flex-1">{t('inventory.recipeDetail.ingredientCount')}</span>
157
- <span className="text-sm font-medium">{ingredients.length}</span>
158
- </div>
159
- </div>
160
-
161
- {/* Instructions */}
162
- {recipe.instructions && (
163
- <div>
164
- <h3 className="text-sm font-semibold text-foreground mb-1">{t('inventory.recipeDetail.instructions')}</h3>
165
- <div className="rounded-xl border bg-card shadow-sm px-4 py-3">
166
- <p className="text-xs text-muted-foreground whitespace-pre-wrap leading-relaxed">{recipe.instructions}</p>
167
- </div>
168
- </div>
169
- )}
170
-
171
- {/* Dates */}
172
- <div className="flex items-center gap-4 text-[10px] text-muted-foreground/50">
173
- <span>{t('inventory.recipeDetail.created')} {recipe.createdAt?.slice(0, 10)}</span>
174
- <span>{t('inventory.recipeDetail.updated')} {recipe.updatedAt?.slice(0, 10)}</span>
175
- </div>
176
- </div>
177
-
178
- {/* Right: Ingredients table */}
179
- <div className="lg:col-span-2">
180
- <h3 className="text-sm font-semibold text-foreground mb-2">{t('inventory.recipeDetail.ingredientsTitle')}</h3>
181
- {ingredients.length === 0 ? (
182
- <div className="rounded-xl border p-8 text-center">
183
- <p className="text-xs text-muted-foreground">{t('inventory.recipeDetail.noIngredients')}</p>
184
- </div>
185
- ) : (
186
- <DataTable columns={ingredientColumns} data={ingredients} variant="card" />
187
- )}
188
- </div>
189
- </div>
190
- </div>
191
- )
192
- }
@@ -1,241 +0,0 @@
1
- import React, { useState } from 'react'
2
- import { Plus, Trash2, GripVertical } from 'lucide-react'
3
- import { useInventoryStore, useInventoryProvider } from '../InventoryContext'
4
- import { toast } from '@fayz-ai/ui'
5
- import { SubpageHeader, useSaveBar } from '@fayz-ai/ui'
6
- import { useTranslation } from '@fayz-ai/core'
7
- import { SearchSelect } from '@fayz-ai/ui'
8
- import { useLimitGuard, invalidateLimit } from '@fayz-ai/saas'
9
- import type { CreateRecipeIngredientInput } from '../types'
10
-
11
- interface FormIngredient {
12
- _id: string
13
- productId: string
14
- productName: string
15
- quantity: number
16
- unitId: string
17
- unitName: string
18
- notes: string
19
- }
20
-
21
- let fid = 1
22
- function nextId() { return `ri${fid++}` }
23
-
24
- export function RecipeFormView({ onSaved }: { onSaved?: (id?: string) => void }) {
25
- const t = useTranslation()
26
- const provider = useInventoryProvider()
27
- const createRecipe = useInventoryStore((s) => s.createRecipe)
28
- const guardRecipes = useLimitGuard('recipes')
29
-
30
- const [name, setName] = useState('')
31
- const [description, setDescription] = useState('')
32
- const [productId, setProductId] = useState('')
33
- const [productName, setProductName] = useState('')
34
- const [yieldQuantity, setYieldQuantity] = useState(1)
35
- const [prepTime, setPrepTime] = useState('')
36
- const [instructions, setInstructions] = useState('')
37
- const [ingredients, setIngredients] = useState<FormIngredient[]>([])
38
- const [saving, setSaving] = useState(false)
39
-
40
- function addIngredient() {
41
- setIngredients([...ingredients, { _id: nextId(), productId: '', productName: '', quantity: 1, unitId: '', unitName: '', notes: '' }])
42
- }
43
-
44
- function updateIngredient(id: string, data: Partial<FormIngredient>) {
45
- setIngredients(ingredients.map((ing) => ing._id === id ? { ...ing, ...data } : ing))
46
- }
47
-
48
- function removeIngredient(id: string) {
49
- setIngredients(ingredients.filter((ing) => ing._id !== id))
50
- }
51
-
52
- async function handleSave() {
53
- if (!name.trim() || !productId || ingredients.length === 0) { toast.error(t('common.formIncomplete')); return }
54
- // Plan quantity guard (client-side, before the provider call). Opens the
55
- // global UpgradeModal and aborts when the plan's recipe cap is reached.
56
- if ((await guardRecipes()) === 'blocked') return
57
- setSaving(true)
58
- try {
59
- const recipe = await createRecipe({
60
- name,
61
- description: description || undefined,
62
- productId,
63
- yieldQuantity,
64
- preparationTimeMinutes: prepTime ? parseInt(prepTime) : undefined,
65
- instructions: instructions || undefined,
66
- ingredients: ingredients.filter((i) => i.productId).map((i, idx) => ({
67
- productId: i.productId,
68
- quantity: i.quantity,
69
- unitId: i.unitId || undefined,
70
- displayOrder: idx,
71
- notes: i.notes || undefined,
72
- })),
73
- })
74
- invalidateLimit('recipes')
75
- onSaved?.(recipe.id)
76
- } finally { setSaving(false) }
77
- }
78
-
79
- const dirty = !!(name || description || productId || prepTime || instructions || ingredients.length > 0 || yieldQuantity !== 1)
80
- useSaveBar({
81
- dirty,
82
- saving,
83
- onSave: () => { void handleSave() },
84
- onDiscard: () => onSaved?.(),
85
- saveLabel: t('inventory.recipeForm.saveRecipe'),
86
- })
87
-
88
- async function searchProducts(query: string) {
89
- const result = await provider.getProducts({ search: query, pageSize: 10 })
90
- return result.data.map((p) => ({ id: p.id, label: p.name, subtitle: p.sku ?? p.productType, data: p }))
91
- }
92
-
93
- async function quickCreateProduct(name: string, type: 'ingredient' | 'sale' = 'ingredient') {
94
- try {
95
- const product = await provider.createProduct({ name, productType: type })
96
- toast.success(`Product "${name}" created`)
97
- return product
98
- } catch {
99
- toast.error('Failed to create product')
100
- return null
101
- }
102
- }
103
-
104
- return (
105
- <div className="space-y-5">
106
- <SubpageHeader
107
- title={t('inventory.recipeForm.newRecipe')}
108
- subtitle={t('inventory.recipeForm.subtitle')}
109
- onBack={() => onSaved?.()}
110
- parentLabel={t('inventory.nav.recipes')}
111
- />
112
-
113
- <div className="grid gap-4 lg:grid-cols-3">
114
- {/* Left: Recipe details */}
115
- <div className="rounded-lg border bg-card shadow-sm p-5 space-y-4">
116
- <h3 className="text-sm font-semibold">{t('inventory.recipeForm.recipeDetails')}</h3>
117
-
118
- <div>
119
- <label className="text-xs font-medium text-muted-foreground">{t('inventory.recipeForm.recipeName')} *</label>
120
- <input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder={t('inventory.recipeForm.recipeNamePlaceholder')} autoFocus 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" />
121
- </div>
122
-
123
- <div>
124
- <label className="text-xs font-medium text-muted-foreground">{t('inventory.recipeForm.description')}</label>
125
- <textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder={t('inventory.recipeForm.descriptionPlaceholder')} 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" />
126
- </div>
127
-
128
- <SearchSelect
129
- label={`${t('inventory.recipeForm.produces')} *`}
130
- value={productId}
131
- displayValue={productName}
132
- onChange={(id, opt) => { setProductId(id); setProductName(opt?.label ?? '') }}
133
- onSearch={searchProducts}
134
- placeholder={t('inventory.recipeForm.searchProduct')}
135
- allowCreate
136
- createLabel={t('inventory.recipeForm.createProduct')}
137
- onCreate={async (q) => {
138
- const p = await quickCreateProduct(q, 'sale')
139
- if (p) { setProductId(p.id); setProductName(p.name) }
140
- }}
141
- />
142
-
143
- <div className="grid grid-cols-2 gap-3">
144
- <div>
145
- <label className="text-xs font-medium text-muted-foreground">{t('inventory.recipeForm.yieldQuantity')}</label>
146
- <input type="number" min={0.01} step={0.01} value={yieldQuantity} onChange={(e) => setYieldQuantity(Number(e.target.value) || 1)} 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" />
147
- </div>
148
- <div>
149
- <label className="text-xs font-medium text-muted-foreground">{t('inventory.recipeForm.prepTime')}</label>
150
- <input type="number" min={0} value={prepTime} onChange={(e) => setPrepTime(e.target.value)} placeholder="—" 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" />
151
- </div>
152
- </div>
153
-
154
- <div>
155
- <label className="text-xs font-medium text-muted-foreground">{t('inventory.recipeForm.instructions')}</label>
156
- <textarea value={instructions} onChange={(e) => setInstructions(e.target.value)} rows={4} placeholder={t('inventory.recipeForm.instructionsPlaceholder')} 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" />
157
- </div>
158
- </div>
159
-
160
- {/* Right: Ingredients */}
161
- <div className="lg:col-span-2 rounded-lg border bg-card shadow-sm overflow-hidden flex flex-col">
162
- <div className="flex items-center justify-between px-4 py-3 border-b">
163
- <h3 className="text-sm font-semibold">{t('inventory.recipeForm.ingredients')}</h3>
164
- <button onClick={addIngredient} className="inline-flex items-center gap-1.5 rounded-lg bg-primary border border-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 shadow-button-primary active:shadow-button-inset transition-colors">
165
- <Plus className="h-3 w-3" /> {t('inventory.recipeForm.addIngredient')}
166
- </button>
167
- </div>
168
-
169
- <div className="flex-1">
170
- {ingredients.length === 0 ? (
171
- <div className="py-12 text-center">
172
- <p className="text-sm text-muted-foreground">{t('inventory.recipeForm.noIngredients')}</p>
173
- <button onClick={addIngredient} className="text-xs text-primary hover:underline mt-1">{t('inventory.recipeForm.addFirstIngredient')}</button>
174
- </div>
175
- ) : (
176
- <div className="divide-y">
177
- {/* Header */}
178
- <div className="grid grid-cols-12 gap-2 px-4 py-2 text-[10px] font-medium text-muted-foreground uppercase tracking-wider bg-muted/20">
179
- <div className="col-span-5">{t('inventory.recipeForm.ingredient')}</div>
180
- <div className="col-span-2">{t('inventory.recipeForm.quantity')}</div>
181
- <div className="col-span-4">{t('inventory.recipeForm.notes')}</div>
182
- <div className="col-span-1" />
183
- </div>
184
- {ingredients.map((ing, idx) => (
185
- <div key={ing._id} className="grid grid-cols-12 gap-2 px-4 py-2.5 items-center group">
186
- <div className="col-span-5">
187
- <SearchSelect
188
- value={ing.productId}
189
- displayValue={ing.productName}
190
- onChange={(id, opt) => updateIngredient(ing._id, { productId: id, productName: opt?.label ?? '' })}
191
- onSearch={searchProducts}
192
- placeholder={t('inventory.recipeForm.searchIngredient')}
193
- allowCreate
194
- createLabel={t('inventory.recipeForm.createIngredient')}
195
- onCreate={async (q) => {
196
- const p = await quickCreateProduct(q, 'ingredient')
197
- if (p) updateIngredient(ing._id, { productId: p.id, productName: p.name })
198
- }}
199
- />
200
- </div>
201
- <div className="col-span-2">
202
- <input
203
- type="number"
204
- min={0.01}
205
- step={0.01}
206
- value={ing.quantity}
207
- onChange={(e) => updateIngredient(ing._id, { quantity: Number(e.target.value) || 0 })}
208
- className="w-full rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
209
- />
210
- </div>
211
- <div className="col-span-4">
212
- <input
213
- type="text"
214
- value={ing.notes}
215
- onChange={(e) => updateIngredient(ing._id, { notes: e.target.value })}
216
- placeholder={t('inventory.recipeForm.notesPlaceholder')}
217
- className="w-full rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
218
- />
219
- </div>
220
- <div className="col-span-1 flex justify-end">
221
- <button onClick={() => removeIngredient(ing._id)} className="p-1 text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-destructive transition-all">
222
- <Trash2 className="h-3.5 w-3.5" />
223
- </button>
224
- </div>
225
- </div>
226
- ))}
227
- </div>
228
- )}
229
- </div>
230
-
231
- {/* Footer */}
232
- {ingredients.length > 0 && (
233
- <div className="px-4 py-3 border-t bg-muted/20 text-xs text-muted-foreground">
234
- {t('inventory.recipeForm.ingredientsConfigured', { configured: String(ingredients.filter((i) => i.productId).length), total: String(ingredients.length) })}
235
- </div>
236
- )}
237
- </div>
238
- </div>
239
- </div>
240
- )
241
- }