@fayz-ai/plugin-inventory 0.1.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.
- package/dist/InventoryContext.d.ts +37 -0
- package/dist/InventoryContext.d.ts.map +1 -0
- package/dist/InventoryPage.d.ts +13 -0
- package/dist/InventoryPage.d.ts.map +1 -0
- package/dist/components/InventoryGeneralSettings.d.ts +3 -0
- package/dist/components/InventoryGeneralSettings.d.ts.map +1 -0
- package/dist/components/InventoryOnboarding.d.ts +5 -0
- package/dist/components/InventoryOnboarding.d.ts.map +1 -0
- package/dist/components/InventorySettings.d.ts +8 -0
- package/dist/components/InventorySettings.d.ts.map +1 -0
- package/dist/data/index.d.ts +3 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/mock.d.ts +3 -0
- package/dist/data/mock.d.ts.map +1 -0
- package/dist/data/supabase.d.ts +3 -0
- package/dist/data/supabase.d.ts.map +1 -0
- package/dist/data/types.d.ts +22 -0
- package/dist/data/types.d.ts.map +1 -0
- package/dist/index.cjs +2936 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2930 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/tenant.d.ts +3 -0
- package/dist/lib/tenant.d.ts.map +1 -0
- package/dist/locales/en.d.ts +2 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/index.d.ts +2 -0
- package/dist/locales/index.d.ts.map +1 -0
- package/dist/locales/pt-BR.d.ts +2 -0
- package/dist/locales/pt-BR.d.ts.map +1 -0
- package/dist/registries.d.ts +3 -0
- package/dist/registries.d.ts.map +1 -0
- package/dist/store.d.ts +28 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/types.d.ts +213 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/views/DashboardView.d.ts +8 -0
- package/dist/views/DashboardView.d.ts.map +1 -0
- package/dist/views/MovementHistoryView.d.ts +5 -0
- package/dist/views/MovementHistoryView.d.ts.map +1 -0
- package/dist/views/ProductCrudForm.d.ts +6 -0
- package/dist/views/ProductCrudForm.d.ts.map +1 -0
- package/dist/views/ProductFormView.d.ts +6 -0
- package/dist/views/ProductFormView.d.ts.map +1 -0
- package/dist/views/ProductListView.d.ts +6 -0
- package/dist/views/ProductListView.d.ts.map +1 -0
- package/dist/views/RecipeDetailView.d.ts +6 -0
- package/dist/views/RecipeDetailView.d.ts.map +1 -0
- package/dist/views/RecipeFormView.d.ts +5 -0
- package/dist/views/RecipeFormView.d.ts.map +1 -0
- package/dist/views/RecipesView.d.ts +6 -0
- package/dist/views/RecipesView.d.ts.map +1 -0
- package/dist/views/StockMovementView.d.ts +8 -0
- package/dist/views/StockMovementView.d.ts.map +1 -0
- package/dist/views/dashboardWidgets.d.ts +11 -0
- package/dist/views/dashboardWidgets.d.ts.map +1 -0
- package/dist/views/productEntity.d.ts +6 -0
- package/dist/views/productEntity.d.ts.map +1 -0
- package/package.json +55 -0
- package/src/InventoryContext.tsx +40 -0
- package/src/InventoryPage.tsx +170 -0
- package/src/README.md +177 -0
- package/src/components/InventoryGeneralSettings.tsx +26 -0
- package/src/components/InventoryOnboarding.tsx +60 -0
- package/src/components/InventorySettings.tsx +27 -0
- package/src/data/index.ts +2 -0
- package/src/data/mock.ts +266 -0
- package/src/data/supabase.ts +358 -0
- package/src/data/types.ts +35 -0
- package/src/index.ts +191 -0
- package/src/lib/tenant.ts +4 -0
- package/src/locales/en.ts +242 -0
- package/src/locales/index.ts +7 -0
- package/src/locales/pt-BR.ts +242 -0
- package/src/migrations/001_inventory_base.sql +69 -0
- package/src/migrations/002_recipes.sql +34 -0
- package/src/migrations/003_measurement_units.sql +13 -0
- package/src/registries.ts +111 -0
- package/src/store.ts +127 -0
- package/src/types.ts +256 -0
- package/src/views/DashboardView.tsx +11 -0
- package/src/views/MovementHistoryView.tsx +104 -0
- package/src/views/ProductCrudForm.tsx +99 -0
- package/src/views/ProductFormView.tsx +283 -0
- package/src/views/ProductListView.tsx +107 -0
- package/src/views/RecipeDetailView.tsx +192 -0
- package/src/views/RecipeFormView.tsx +235 -0
- package/src/views/RecipesView.tsx +103 -0
- package/src/views/StockMovementView.tsx +516 -0
- package/src/views/dashboardWidgets.tsx +101 -0
- package/src/views/productEntity.tsx +124 -0
|
@@ -0,0 +1,235 @@
|
|
|
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 type { CreateRecipeIngredientInput } from '../types'
|
|
9
|
+
|
|
10
|
+
interface FormIngredient {
|
|
11
|
+
_id: string
|
|
12
|
+
productId: string
|
|
13
|
+
productName: string
|
|
14
|
+
quantity: number
|
|
15
|
+
unitId: string
|
|
16
|
+
unitName: string
|
|
17
|
+
notes: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let fid = 1
|
|
21
|
+
function nextId() { return `ri${fid++}` }
|
|
22
|
+
|
|
23
|
+
export function RecipeFormView({ onSaved }: { onSaved?: (id?: string) => void }) {
|
|
24
|
+
const t = useTranslation()
|
|
25
|
+
const provider = useInventoryProvider()
|
|
26
|
+
const createRecipe = useInventoryStore((s) => s.createRecipe)
|
|
27
|
+
|
|
28
|
+
const [name, setName] = useState('')
|
|
29
|
+
const [description, setDescription] = useState('')
|
|
30
|
+
const [productId, setProductId] = useState('')
|
|
31
|
+
const [productName, setProductName] = useState('')
|
|
32
|
+
const [yieldQuantity, setYieldQuantity] = useState(1)
|
|
33
|
+
const [prepTime, setPrepTime] = useState('')
|
|
34
|
+
const [instructions, setInstructions] = useState('')
|
|
35
|
+
const [ingredients, setIngredients] = useState<FormIngredient[]>([])
|
|
36
|
+
const [saving, setSaving] = useState(false)
|
|
37
|
+
|
|
38
|
+
function addIngredient() {
|
|
39
|
+
setIngredients([...ingredients, { _id: nextId(), productId: '', productName: '', quantity: 1, unitId: '', unitName: '', notes: '' }])
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function updateIngredient(id: string, data: Partial<FormIngredient>) {
|
|
43
|
+
setIngredients(ingredients.map((ing) => ing._id === id ? { ...ing, ...data } : ing))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function removeIngredient(id: string) {
|
|
47
|
+
setIngredients(ingredients.filter((ing) => ing._id !== id))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function handleSave() {
|
|
51
|
+
if (!name.trim() || !productId || ingredients.length === 0) { toast.error(t('common.formIncomplete')); return }
|
|
52
|
+
setSaving(true)
|
|
53
|
+
try {
|
|
54
|
+
const recipe = await createRecipe({
|
|
55
|
+
name,
|
|
56
|
+
description: description || undefined,
|
|
57
|
+
productId,
|
|
58
|
+
yieldQuantity,
|
|
59
|
+
preparationTimeMinutes: prepTime ? parseInt(prepTime) : undefined,
|
|
60
|
+
instructions: instructions || undefined,
|
|
61
|
+
ingredients: ingredients.filter((i) => i.productId).map((i, idx) => ({
|
|
62
|
+
productId: i.productId,
|
|
63
|
+
quantity: i.quantity,
|
|
64
|
+
unitId: i.unitId || undefined,
|
|
65
|
+
displayOrder: idx,
|
|
66
|
+
notes: i.notes || undefined,
|
|
67
|
+
})),
|
|
68
|
+
})
|
|
69
|
+
onSaved?.(recipe.id)
|
|
70
|
+
} finally { setSaving(false) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const dirty = !!(name || description || productId || prepTime || instructions || ingredients.length > 0 || yieldQuantity !== 1)
|
|
74
|
+
useSaveBar({
|
|
75
|
+
dirty,
|
|
76
|
+
saving,
|
|
77
|
+
onSave: () => { void handleSave() },
|
|
78
|
+
onDiscard: () => onSaved?.(),
|
|
79
|
+
saveLabel: t('inventory.recipeForm.saveRecipe'),
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
async function searchProducts(query: string) {
|
|
83
|
+
const result = await provider.getProducts({ search: query, pageSize: 10 })
|
|
84
|
+
return result.data.map((p) => ({ id: p.id, label: p.name, subtitle: p.sku ?? p.productType, data: p }))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function quickCreateProduct(name: string, type: 'ingredient' | 'sale' = 'ingredient') {
|
|
88
|
+
try {
|
|
89
|
+
const product = await provider.createProduct({ name, productType: type })
|
|
90
|
+
toast.success(`Product "${name}" created`)
|
|
91
|
+
return product
|
|
92
|
+
} catch {
|
|
93
|
+
toast.error('Failed to create product')
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className="space-y-5">
|
|
100
|
+
<SubpageHeader
|
|
101
|
+
title={t('inventory.recipeForm.newRecipe')}
|
|
102
|
+
subtitle={t('inventory.recipeForm.subtitle')}
|
|
103
|
+
onBack={() => onSaved?.()}
|
|
104
|
+
parentLabel={t('inventory.nav.recipes')}
|
|
105
|
+
/>
|
|
106
|
+
|
|
107
|
+
<div className="grid gap-4 lg:grid-cols-3">
|
|
108
|
+
{/* Left: Recipe details */}
|
|
109
|
+
<div className="rounded-lg border bg-card shadow-sm p-5 space-y-4">
|
|
110
|
+
<h3 className="text-sm font-semibold">{t('inventory.recipeForm.recipeDetails')}</h3>
|
|
111
|
+
|
|
112
|
+
<div>
|
|
113
|
+
<label className="text-xs font-medium text-muted-foreground">{t('inventory.recipeForm.recipeName')} *</label>
|
|
114
|
+
<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" />
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div>
|
|
118
|
+
<label className="text-xs font-medium text-muted-foreground">{t('inventory.recipeForm.description')}</label>
|
|
119
|
+
<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" />
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<SearchSelect
|
|
123
|
+
label={`${t('inventory.recipeForm.produces')} *`}
|
|
124
|
+
value={productId}
|
|
125
|
+
displayValue={productName}
|
|
126
|
+
onChange={(id, opt) => { setProductId(id); setProductName(opt?.label ?? '') }}
|
|
127
|
+
onSearch={searchProducts}
|
|
128
|
+
placeholder={t('inventory.recipeForm.searchProduct')}
|
|
129
|
+
allowCreate
|
|
130
|
+
createLabel={t('inventory.recipeForm.createProduct')}
|
|
131
|
+
onCreate={async (q) => {
|
|
132
|
+
const p = await quickCreateProduct(q, 'sale')
|
|
133
|
+
if (p) { setProductId(p.id); setProductName(p.name) }
|
|
134
|
+
}}
|
|
135
|
+
/>
|
|
136
|
+
|
|
137
|
+
<div className="grid grid-cols-2 gap-3">
|
|
138
|
+
<div>
|
|
139
|
+
<label className="text-xs font-medium text-muted-foreground">{t('inventory.recipeForm.yieldQuantity')}</label>
|
|
140
|
+
<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" />
|
|
141
|
+
</div>
|
|
142
|
+
<div>
|
|
143
|
+
<label className="text-xs font-medium text-muted-foreground">{t('inventory.recipeForm.prepTime')}</label>
|
|
144
|
+
<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" />
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<div>
|
|
149
|
+
<label className="text-xs font-medium text-muted-foreground">{t('inventory.recipeForm.instructions')}</label>
|
|
150
|
+
<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" />
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{/* Right: Ingredients */}
|
|
155
|
+
<div className="lg:col-span-2 rounded-lg border bg-card shadow-sm overflow-hidden flex flex-col">
|
|
156
|
+
<div className="flex items-center justify-between px-4 py-3 border-b">
|
|
157
|
+
<h3 className="text-sm font-semibold">{t('inventory.recipeForm.ingredients')}</h3>
|
|
158
|
+
<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">
|
|
159
|
+
<Plus className="h-3 w-3" /> {t('inventory.recipeForm.addIngredient')}
|
|
160
|
+
</button>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div className="flex-1">
|
|
164
|
+
{ingredients.length === 0 ? (
|
|
165
|
+
<div className="py-12 text-center">
|
|
166
|
+
<p className="text-sm text-muted-foreground">{t('inventory.recipeForm.noIngredients')}</p>
|
|
167
|
+
<button onClick={addIngredient} className="text-xs text-primary hover:underline mt-1">{t('inventory.recipeForm.addFirstIngredient')}</button>
|
|
168
|
+
</div>
|
|
169
|
+
) : (
|
|
170
|
+
<div className="divide-y">
|
|
171
|
+
{/* Header */}
|
|
172
|
+
<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">
|
|
173
|
+
<div className="col-span-5">{t('inventory.recipeForm.ingredient')}</div>
|
|
174
|
+
<div className="col-span-2">{t('inventory.recipeForm.quantity')}</div>
|
|
175
|
+
<div className="col-span-4">{t('inventory.recipeForm.notes')}</div>
|
|
176
|
+
<div className="col-span-1" />
|
|
177
|
+
</div>
|
|
178
|
+
{ingredients.map((ing, idx) => (
|
|
179
|
+
<div key={ing._id} className="grid grid-cols-12 gap-2 px-4 py-2.5 items-center group">
|
|
180
|
+
<div className="col-span-5">
|
|
181
|
+
<SearchSelect
|
|
182
|
+
value={ing.productId}
|
|
183
|
+
displayValue={ing.productName}
|
|
184
|
+
onChange={(id, opt) => updateIngredient(ing._id, { productId: id, productName: opt?.label ?? '' })}
|
|
185
|
+
onSearch={searchProducts}
|
|
186
|
+
placeholder={t('inventory.recipeForm.searchIngredient')}
|
|
187
|
+
allowCreate
|
|
188
|
+
createLabel={t('inventory.recipeForm.createIngredient')}
|
|
189
|
+
onCreate={async (q) => {
|
|
190
|
+
const p = await quickCreateProduct(q, 'ingredient')
|
|
191
|
+
if (p) updateIngredient(ing._id, { productId: p.id, productName: p.name })
|
|
192
|
+
}}
|
|
193
|
+
/>
|
|
194
|
+
</div>
|
|
195
|
+
<div className="col-span-2">
|
|
196
|
+
<input
|
|
197
|
+
type="number"
|
|
198
|
+
min={0.01}
|
|
199
|
+
step={0.01}
|
|
200
|
+
value={ing.quantity}
|
|
201
|
+
onChange={(e) => updateIngredient(ing._id, { quantity: Number(e.target.value) || 0 })}
|
|
202
|
+
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"
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
205
|
+
<div className="col-span-4">
|
|
206
|
+
<input
|
|
207
|
+
type="text"
|
|
208
|
+
value={ing.notes}
|
|
209
|
+
onChange={(e) => updateIngredient(ing._id, { notes: e.target.value })}
|
|
210
|
+
placeholder={t('inventory.recipeForm.notesPlaceholder')}
|
|
211
|
+
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"
|
|
212
|
+
/>
|
|
213
|
+
</div>
|
|
214
|
+
<div className="col-span-1 flex justify-end">
|
|
215
|
+
<button onClick={() => removeIngredient(ing._id)} className="p-1 text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-destructive transition-all">
|
|
216
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
217
|
+
</button>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
))}
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{/* Footer */}
|
|
226
|
+
{ingredients.length > 0 && (
|
|
227
|
+
<div className="px-4 py-3 border-t bg-muted/20 text-xs text-muted-foreground">
|
|
228
|
+
{t('inventory.recipeForm.ingredientsConfigured', { configured: String(ingredients.filter((i) => i.productId).length), total: String(ingredients.length) })}
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
)
|
|
235
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React, { useEffect } from 'react'
|
|
2
|
+
import { BookOpen, Plus, Clock, Layers } from 'lucide-react'
|
|
3
|
+
import { useInventoryStore } from '../InventoryContext'
|
|
4
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
5
|
+
import { SubpageHeader } from '@fayz-ai/ui'
|
|
6
|
+
|
|
7
|
+
function RecipeSkeleton() {
|
|
8
|
+
return (
|
|
9
|
+
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
10
|
+
{[1, 2, 3, 4, 5, 6].map((i) => (
|
|
11
|
+
<div key={i} className="rounded-lg border bg-card shadow-sm p-4 space-y-3">
|
|
12
|
+
<div className="flex items-center gap-3">
|
|
13
|
+
<div className="h-9 w-9 rounded-lg bg-muted/40 animate-pulse" />
|
|
14
|
+
<div className="flex-1 space-y-1.5">
|
|
15
|
+
<div className="h-4 w-2/3 rounded bg-muted/40 animate-pulse" />
|
|
16
|
+
<div className="h-3 w-1/2 rounded bg-muted/30 animate-pulse" />
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
<div className="flex gap-3">
|
|
20
|
+
<div className="h-3 w-20 rounded bg-muted/30 animate-pulse" />
|
|
21
|
+
<div className="h-3 w-16 rounded bg-muted/30 animate-pulse" />
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
))}
|
|
25
|
+
</div>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function RecipesView({ onNew, onView }: { onNew?: () => void; onView?: (id: string) => void }) {
|
|
30
|
+
const t = useTranslation()
|
|
31
|
+
const recipes = useInventoryStore((s) => s.recipes)
|
|
32
|
+
const recipesLoading = useInventoryStore((s) => s.recipesLoading)
|
|
33
|
+
const fetchRecipes = useInventoryStore((s) => s.fetchRecipes)
|
|
34
|
+
|
|
35
|
+
useEffect(() => { fetchRecipes() }, [])
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="space-y-4">
|
|
39
|
+
<SubpageHeader
|
|
40
|
+
title={t('inventory.recipes.title')}
|
|
41
|
+
subtitle={t('inventory.recipes.productionFormulas', { count: String(recipes.length) })}
|
|
42
|
+
actions={onNew && (
|
|
43
|
+
<button onClick={onNew} className="inline-flex items-center gap-1.5 rounded-lg bg-primary border border-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 shadow-button-primary active:shadow-button-inset transition-colors">
|
|
44
|
+
<Plus className="h-3.5 w-3.5" /> {t('inventory.recipes.newRecipe')}
|
|
45
|
+
</button>
|
|
46
|
+
)}
|
|
47
|
+
/>
|
|
48
|
+
|
|
49
|
+
{recipesLoading ? (
|
|
50
|
+
<RecipeSkeleton />
|
|
51
|
+
) : recipes.length === 0 ? (
|
|
52
|
+
<div className="flex flex-col items-center justify-center py-16 text-center rounded-lg border-2 border-dashed border-muted">
|
|
53
|
+
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-muted/30 mb-3">
|
|
54
|
+
<BookOpen className="h-5 w-5 text-muted-foreground/40" />
|
|
55
|
+
</div>
|
|
56
|
+
<p className="text-sm text-muted-foreground">{t('inventory.recipes.noRecipes')}</p>
|
|
57
|
+
<p className="text-[10px] text-muted-foreground/60 mt-0.5">{t('inventory.recipes.recipesDesc')}</p>
|
|
58
|
+
{onNew && <button onClick={onNew} className="text-xs text-primary hover:underline mt-2">{t('inventory.recipes.createFirst')}</button>}
|
|
59
|
+
</div>
|
|
60
|
+
) : (
|
|
61
|
+
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
62
|
+
{recipes.map((r) => (
|
|
63
|
+
<div
|
|
64
|
+
key={r.id}
|
|
65
|
+
onClick={() => onView?.(r.id)}
|
|
66
|
+
className="rounded-lg border bg-card p-4 hover:shadow-sm hover:border-primary/20 transition-all cursor-pointer group"
|
|
67
|
+
>
|
|
68
|
+
<div className="flex items-center gap-3">
|
|
69
|
+
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary shrink-0 group-hover:bg-primary/15 transition-colors">
|
|
70
|
+
<BookOpen className="h-4 w-4" />
|
|
71
|
+
</div>
|
|
72
|
+
<div className="min-w-0">
|
|
73
|
+
<h3 className="text-sm font-semibold truncate">{r.name}</h3>
|
|
74
|
+
{r.productName && <p className="text-[10px] text-muted-foreground truncate">{t('inventory.recipes.produces')} {r.productName}</p>}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
{r.description && (
|
|
78
|
+
<p className="text-xs text-muted-foreground mt-2 line-clamp-2">{r.description}</p>
|
|
79
|
+
)}
|
|
80
|
+
<div className="flex items-center gap-4 mt-3 pt-2.5 border-t">
|
|
81
|
+
{r.ingredientCount != null && (
|
|
82
|
+
<span className="inline-flex items-center gap-1 text-[10px] text-muted-foreground">
|
|
83
|
+
<Layers className="h-3 w-3" /> {t('inventory.recipes.ingredients', { count: String(r.ingredientCount) })}
|
|
84
|
+
</span>
|
|
85
|
+
)}
|
|
86
|
+
{r.preparationTimeMinutes != null && (
|
|
87
|
+
<span className="inline-flex items-center gap-1 text-[10px] text-muted-foreground">
|
|
88
|
+
<Clock className="h-3 w-3" /> {r.preparationTimeMinutes} min
|
|
89
|
+
</span>
|
|
90
|
+
)}
|
|
91
|
+
{r.yieldQuantity > 0 && (
|
|
92
|
+
<span className="text-[10px] text-muted-foreground ml-auto">
|
|
93
|
+
{t('inventory.recipes.yield')} {r.yieldQuantity}{r.yieldUnitName ? ` ${r.yieldUnitName}` : ''}
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
)
|
|
103
|
+
}
|