@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,283 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
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'
|
|
7
|
+
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
|
+
]
|
|
61
|
+
|
|
62
|
+
export function ProductListView({ onNew, onEdit }: {
|
|
63
|
+
onNew?: () => void
|
|
64
|
+
onEdit?: (id: string) => void
|
|
65
|
+
}) {
|
|
66
|
+
const t = useTranslation()
|
|
67
|
+
const { currency, productTypes } = useInventoryConfig()
|
|
68
|
+
const products = useInventoryStore((s) => s.products)
|
|
69
|
+
const productsTotal = useInventoryStore((s) => s.productsTotal)
|
|
70
|
+
const productsLoading = useInventoryStore((s) => s.productsLoading)
|
|
71
|
+
const fetchProducts = useInventoryStore((s) => s.fetchProducts)
|
|
72
|
+
|
|
73
|
+
const [search, setSearch] = useState('')
|
|
74
|
+
const [typeFilter, setTypeFilter] = useState<string | undefined>()
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
fetchProducts({ productType: typeFilter as ProductType | undefined, search: search || undefined })
|
|
78
|
+
}, [typeFilter, search])
|
|
79
|
+
|
|
80
|
+
const columns = useColumns(currency, t)
|
|
81
|
+
|
|
82
|
+
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>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
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
|
+
}
|