@fayz-ai/plugin-inventory 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,106 +0,0 @@
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
- import { PermissionGate } from '@fayz-ai/saas'
7
-
8
- function RecipeSkeleton() {
9
- return (
10
- <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
11
- {[1, 2, 3, 4, 5, 6].map((i) => (
12
- <div key={i} className="rounded-lg border bg-card shadow-sm p-4 space-y-3">
13
- <div className="flex items-center gap-3">
14
- <div className="h-9 w-9 rounded-lg bg-muted/40 animate-pulse" />
15
- <div className="flex-1 space-y-1.5">
16
- <div className="h-4 w-2/3 rounded bg-muted/40 animate-pulse" />
17
- <div className="h-3 w-1/2 rounded bg-muted/30 animate-pulse" />
18
- </div>
19
- </div>
20
- <div className="flex gap-3">
21
- <div className="h-3 w-20 rounded bg-muted/30 animate-pulse" />
22
- <div className="h-3 w-16 rounded bg-muted/30 animate-pulse" />
23
- </div>
24
- </div>
25
- ))}
26
- </div>
27
- )
28
- }
29
-
30
- export function RecipesView({ onNew, onView }: { onNew?: () => void; onView?: (id: string) => void }) {
31
- const t = useTranslation()
32
- const recipes = useInventoryStore((s) => s.recipes)
33
- const recipesLoading = useInventoryStore((s) => s.recipesLoading)
34
- const fetchRecipes = useInventoryStore((s) => s.fetchRecipes)
35
-
36
- useEffect(() => { fetchRecipes() }, [])
37
-
38
- return (
39
- <div className="space-y-4">
40
- <SubpageHeader
41
- title={t('inventory.recipes.title')}
42
- subtitle={t('inventory.recipes.productionFormulas', { count: String(recipes.length) })}
43
- actions={onNew && (
44
- <PermissionGate feature="inventory" action="create">
45
- <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">
46
- <Plus className="h-3.5 w-3.5" /> {t('inventory.recipes.newRecipe')}
47
- </button>
48
- </PermissionGate>
49
- )}
50
- />
51
-
52
- {recipesLoading ? (
53
- <RecipeSkeleton />
54
- ) : recipes.length === 0 ? (
55
- <div className="flex flex-col items-center justify-center py-16 text-center rounded-lg border-2 border-dashed border-muted">
56
- <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-muted/30 mb-3">
57
- <BookOpen className="h-5 w-5 text-muted-foreground/40" />
58
- </div>
59
- <p className="text-sm text-muted-foreground">{t('inventory.recipes.noRecipes')}</p>
60
- <p className="text-[10px] text-muted-foreground/60 mt-0.5">{t('inventory.recipes.recipesDesc')}</p>
61
- {onNew && <button onClick={onNew} className="text-xs text-primary hover:underline mt-2">{t('inventory.recipes.createFirst')}</button>}
62
- </div>
63
- ) : (
64
- <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
65
- {recipes.map((r) => (
66
- <div
67
- key={r.id}
68
- onClick={() => onView?.(r.id)}
69
- className="rounded-lg border bg-card p-4 hover:shadow-sm hover:border-primary/20 transition-all cursor-pointer group"
70
- >
71
- <div className="flex items-center gap-3">
72
- <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">
73
- <BookOpen className="h-4 w-4" />
74
- </div>
75
- <div className="min-w-0">
76
- <h3 className="text-sm font-semibold truncate">{r.name}</h3>
77
- {r.productName && <p className="text-[10px] text-muted-foreground truncate">{t('inventory.recipes.produces')} {r.productName}</p>}
78
- </div>
79
- </div>
80
- {r.description && (
81
- <p className="text-xs text-muted-foreground mt-2 line-clamp-2">{r.description}</p>
82
- )}
83
- <div className="flex items-center gap-4 mt-3 pt-2.5 border-t">
84
- {r.ingredientCount != null && (
85
- <span className="inline-flex items-center gap-1 text-[10px] text-muted-foreground">
86
- <Layers className="h-3 w-3" /> {t('inventory.recipes.ingredients', { count: String(r.ingredientCount) })}
87
- </span>
88
- )}
89
- {r.preparationTimeMinutes != null && (
90
- <span className="inline-flex items-center gap-1 text-[10px] text-muted-foreground">
91
- <Clock className="h-3 w-3" /> {r.preparationTimeMinutes} min
92
- </span>
93
- )}
94
- {r.yieldQuantity > 0 && (
95
- <span className="text-[10px] text-muted-foreground ml-auto">
96
- {t('inventory.recipes.yield')} {r.yieldQuantity}{r.yieldUnitName ? ` ${r.yieldUnitName}` : ''}
97
- </span>
98
- )}
99
- </div>
100
- </div>
101
- ))}
102
- </div>
103
- )}
104
- </div>
105
- )
106
- }
@@ -1,518 +0,0 @@
1
- import React, { useState, useEffect, useMemo } from 'react'
2
- import { Save, Plus, ArrowUpRight, ArrowDownRight, RefreshCw, ArrowRightLeft, Trash2, Package, Check, Eye } from 'lucide-react'
3
- import { useInventoryConfig, useInventoryStore, useInventoryProvider, formatCurrency } from '../InventoryContext'
4
- import { cn } from '@fayz-ai/ui'
5
- import { SubpageHeader } from '@fayz-ai/ui'
6
- import { useTranslation } from '@fayz-ai/core'
7
- import { SearchSelect, type SearchSelectOption } from '@fayz-ai/ui'
8
- import { DatePicker } from '@fayz-ai/ui'
9
- import type { MovementType, StockMovement, StockPosition } from '../types'
10
-
11
- // ---------------------------------------------------------------------------
12
- // Movement type config
13
- // ---------------------------------------------------------------------------
14
-
15
- const MOVEMENT_TYPES: { value: MovementType; labelKey: string; icon: React.ElementType; color: string; activeColor: string; descKey: string }[] = [
16
- { value: 'entry', labelKey: 'inventory.stock.entryLabel', icon: ArrowDownRight, color: 'text-success', activeColor: 'border-success bg-success/10 text-success dark:bg-success/10', descKey: 'inventory.stock.entryDesc' },
17
- { value: 'exit', labelKey: 'inventory.stock.exitLabel', icon: ArrowUpRight, color: 'text-destructive', activeColor: 'border-destructive bg-destructive/10 text-destructive dark:bg-destructive/10', descKey: 'inventory.stock.exitDesc' },
18
- { value: 'adjustment', labelKey: 'inventory.stock.adjustmentLabel', icon: RefreshCw, color: 'text-info', activeColor: 'border-info bg-info/10 text-info dark:bg-info/10', descKey: 'inventory.stock.adjustmentDesc' },
19
- { value: 'transfer', labelKey: 'inventory.stock.transferLabel', icon: ArrowRightLeft, color: 'text-magic', activeColor: 'border-magic bg-magic/10 text-magic dark:bg-magic/10', descKey: 'inventory.stock.transferDesc' },
20
- { value: 'loss', labelKey: 'inventory.stock.lossLabel', icon: Trash2, color: 'text-warning', activeColor: 'border-warning bg-warning/10 text-warning dark:bg-warning/10', descKey: 'inventory.stock.lossDesc' },
21
- ]
22
-
23
- // ---------------------------------------------------------------------------
24
- // Movement detail card (for viewing a recorded movement)
25
- // ---------------------------------------------------------------------------
26
-
27
- function MovementDetail({ movement, currency, onClose }: {
28
- movement: StockMovement
29
- currency: { code: string; locale: string; symbol: string }
30
- onClose: () => void
31
- }) {
32
- const t = useTranslation()
33
- const typeConfig = MOVEMENT_TYPES.find((mt) => mt.value === movement.movementType)
34
- const TypeIcon = typeConfig?.icon ?? Package
35
-
36
- return (
37
- <div className="space-y-4">
38
- <SubpageHeader title={movement.productName ?? `#${movement.id.slice(0, 8)}`} onBack={onClose} parentLabel={t('inventory.nav.stock')} />
39
-
40
- <div className="rounded-lg border bg-card shadow-sm p-5 space-y-4">
41
- {/* Type + Product hero */}
42
- <div className="flex items-center gap-3">
43
- <div className={`flex h-10 w-10 items-center justify-center rounded-xl ${typeConfig?.activeColor ?? 'bg-muted'}`}>
44
- <TypeIcon className="h-5 w-5" />
45
- </div>
46
- <div>
47
- <p className="text-lg font-bold">{movement.productName ?? t('inventory.stock.unknownProduct')}</p>
48
- <p className="text-xs text-muted-foreground capitalize">{movement.movementType} &middot; {movement.movementDate}</p>
49
- </div>
50
- </div>
51
-
52
- {/* Key details grid */}
53
- <div className="grid gap-3 sm:grid-cols-3 pt-2 border-t">
54
- <div>
55
- <p className="text-[10px] text-muted-foreground uppercase">{t('inventory.stock.quantityLabel')}</p>
56
- <p className="text-sm font-semibold">{movement.movementType === 'entry' ? '+' : '-'}{movement.quantity}</p>
57
- </div>
58
- <div>
59
- <p className="text-[10px] text-muted-foreground uppercase">{t('inventory.stock.unitCostLabel')}</p>
60
- <p className="text-sm font-semibold">{formatCurrency(movement.unitCost, currency)}</p>
61
- </div>
62
- <div>
63
- <p className="text-[10px] text-muted-foreground uppercase">{t('inventory.stock.totalCost')}</p>
64
- <p className="text-sm font-semibold">{formatCurrency(movement.totalCost, currency)}</p>
65
- </div>
66
- </div>
67
-
68
- {/* Optional details */}
69
- <div className="grid gap-3 sm:grid-cols-2 pt-2 border-t">
70
- {movement.stockLocationName && (
71
- <div>
72
- <p className="text-[10px] text-muted-foreground uppercase">{t('inventory.stock.locationLabel')}</p>
73
- <p className="text-sm">{movement.stockLocationName}</p>
74
- </div>
75
- )}
76
- {movement.destinationLocationName && (
77
- <div>
78
- <p className="text-[10px] text-muted-foreground uppercase">{t('inventory.stock.destination')}</p>
79
- <p className="text-sm">{movement.destinationLocationName}</p>
80
- </div>
81
- )}
82
- {movement.supplierName && (
83
- <div>
84
- <p className="text-[10px] text-muted-foreground uppercase">{t('inventory.stock.supplier')}</p>
85
- <p className="text-sm">{movement.supplierName}</p>
86
- </div>
87
- )}
88
- {movement.documentNumber && (
89
- <div>
90
- <p className="text-[10px] text-muted-foreground uppercase">{t('inventory.stock.document')}</p>
91
- <p className="text-sm">{movement.documentNumber}</p>
92
- </div>
93
- )}
94
- {movement.reason && (
95
- <div>
96
- <p className="text-[10px] text-muted-foreground uppercase">{t('inventory.stock.reasonLabel')}</p>
97
- <p className="text-sm">{movement.reason}</p>
98
- </div>
99
- )}
100
- </div>
101
-
102
- {movement.notes && (
103
- <div className="pt-2 border-t">
104
- <p className="text-[10px] text-muted-foreground uppercase">{t('inventory.stock.notes')}</p>
105
- <p className="text-sm mt-0.5">{movement.notes}</p>
106
- </div>
107
- )}
108
-
109
- <div className="pt-2 border-t">
110
- <p className="text-[10px] text-muted-foreground">{t('inventory.stock.recorded')} {new Date(movement.createdAt).toLocaleString()}</p>
111
- </div>
112
- </div>
113
- </div>
114
- )
115
- }
116
-
117
- // ---------------------------------------------------------------------------
118
- // Stock movement form
119
- // ---------------------------------------------------------------------------
120
-
121
- export function StockMovementView({ defaultType, onSaved, viewMovement }: {
122
- defaultType: MovementType
123
- onSaved?: () => void
124
- viewMovement?: StockMovement
125
- }) {
126
- const t = useTranslation()
127
- const { currency, modules } = useInventoryConfig()
128
- const provider = useInventoryProvider()
129
- const products = useInventoryStore((s) => s.products)
130
- const locations = useInventoryStore((s) => s.locations)
131
- const fetchProducts = useInventoryStore((s) => s.fetchProducts)
132
- const fetchLocations = useInventoryStore((s) => s.fetchLocations)
133
- const createMovement = useInventoryStore((s) => s.createMovement)
134
-
135
- const [step, setStep] = useState(1)
136
- const [positions, setPositions] = useState<StockPosition[]>([])
137
- const [movementType, setMovementType] = useState<MovementType>(defaultType)
138
- const [productId, setProductId] = useState('')
139
- const [productLabel, setProductLabel] = useState('')
140
- const [quantity, setQuantity] = useState(1)
141
- const [unitCost, setUnitCost] = useState(0)
142
- const [locationId, setLocationId] = useState('')
143
- const [destLocationId, setDestLocationId] = useState('')
144
- const [reason, setReason] = useState('')
145
- const [documentNumber, setDocumentNumber] = useState('')
146
- const [notes, setNotes] = useState('')
147
- const [batchNumber, setBatchNumber] = useState('')
148
- const [expirationDate, setExpirationDate] = useState('')
149
- const [saving, setSaving] = useState(false)
150
- const [savedMovement, setSavedMovement] = useState<StockMovement | null>(null)
151
-
152
- useEffect(() => { fetchProducts({}); fetchLocations() }, [])
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
-
172
- // If viewing a movement detail
173
- if (viewMovement) {
174
- return <MovementDetail movement={viewMovement} currency={currency} onClose={() => onSaved?.()} />
175
- }
176
-
177
- // If just saved — show success with details
178
- if (savedMovement) {
179
- return <MovementDetail movement={savedMovement} currency={currency} onClose={() => onSaved?.()} />
180
- }
181
-
182
- const selectedProduct = products.find((p) => p.id === productId)
183
- const totalCost = quantity * unitCost
184
- const typeConfig = MOVEMENT_TYPES.find((t) => t.value === movementType)!
185
- const needsReason = movementType === 'adjustment' || movementType === 'loss'
186
- const needsDest = movementType === 'transfer'
187
-
188
- // Product search
189
- function searchProducts(query: string): SearchSelectOption[] {
190
- const q = query.toLowerCase()
191
- return products
192
- .filter((p) => p.name.toLowerCase().includes(q) || p.sku?.toLowerCase().includes(q) || p.barcode?.includes(q))
193
- .slice(0, 20)
194
- .map((p) => ({
195
- id: p.id,
196
- label: p.name,
197
- subtitle: [p.sku, `${t('inventory.stock.currentStock')} ${p.currentQuantity}`].filter(Boolean).join(' · '),
198
- group: p.productType,
199
- icon: <Package className="h-4 w-4 text-muted-foreground shrink-0" />,
200
- data: p,
201
- }))
202
- }
203
-
204
- // Auto-fill cost when product selected + fetch positions
205
- function handleProductSelect(id: string, option: SearchSelectOption | null) {
206
- setProductId(id)
207
- setProductLabel(option?.label ?? '')
208
- if (option?.data?.costPrice && unitCost === 0) {
209
- setUnitCost(option.data.costPrice)
210
- }
211
- // Fetch stock positions per location for this product
212
- if (id) {
213
- provider.getPositions(id).then(setPositions).catch(() => setPositions([]))
214
- } else {
215
- setPositions([])
216
- }
217
- }
218
-
219
- const canProceedStep1 = !!productId
220
- const canProceedStep2 = quantity > 0 && (!needsReason || reason.trim()) && (!needsDest || destLocationId)
221
- const title = defaultType === 'entry' ? t('inventory.stock.entry') : defaultType === 'exit' ? t('inventory.stock.exit') : t('inventory.stock.movement')
222
-
223
- async function handleSave() {
224
- if (!productId || quantity <= 0) return
225
- setSaving(true)
226
- try {
227
- const movement = await createMovement({
228
- productId,
229
- quantity,
230
- movementType,
231
- unitCost: unitCost || undefined,
232
- stockLocationId: locationId || undefined,
233
- destinationLocationId: destLocationId || undefined,
234
- reason: reason || undefined,
235
- documentNumber: documentNumber || undefined,
236
- notes: notes || undefined,
237
- batchNumber: batchNumber || undefined,
238
- expirationDate: expirationDate || undefined,
239
- })
240
- setSavedMovement(movement)
241
- } finally {
242
- setSaving(false)
243
- }
244
- }
245
-
246
- return (
247
- <div className="space-y-6">
248
- <SubpageHeader
249
- title={title}
250
- subtitle={t('inventory.stock.stepOf', { step: String(step) })}
251
- onBack={step > 1 ? () => setStep(step - 1) : onSaved}
252
- parentLabel={t('inventory.nav.stock')}
253
- />
254
-
255
- {/* Progress bar */}
256
- <div className="flex gap-1">
257
- {[1, 2, 3].map((s) => (
258
- <div key={s} className={`h-1 flex-1 rounded-full transition-colors ${s <= step ? 'bg-primary' : 'bg-muted'}`} />
259
- ))}
260
- </div>
261
-
262
- {/* Step 1: What — Type + Product */}
263
- {step === 1 && (
264
- <div className="space-y-5">
265
- {/* Movement type selector */}
266
- <div>
267
- <p className="text-xs font-medium text-muted-foreground mb-2">{t('inventory.stock.movementType')}</p>
268
- <div className="grid gap-2 sm:grid-cols-5">
269
- {MOVEMENT_TYPES.map((mt) => {
270
- const Icon = mt.icon
271
- const active = movementType === mt.value
272
- return (
273
- <button
274
- key={mt.value}
275
- onClick={() => setMovementType(mt.value)}
276
- className={`rounded-lg border-2 p-3 text-left transition-all ${active ? mt.activeColor : 'border-transparent bg-card hover:bg-muted/30'}`}
277
- >
278
- <Icon className="h-4 w-4 mb-1" />
279
- <p className="text-xs font-medium">{t(mt.labelKey)}</p>
280
- <p className="text-[9px] text-muted-foreground mt-0.5 hidden sm:block">{t(mt.descKey)}</p>
281
- </button>
282
- )
283
- })}
284
- </div>
285
- </div>
286
-
287
- {/* Product search */}
288
- <SearchSelect
289
- value={productId}
290
- displayValue={productLabel}
291
- onChange={handleProductSelect}
292
- onSearch={searchProducts}
293
- label={t('inventory.stock.product')}
294
- required
295
- placeholder={t('inventory.stock.searchProduct')}
296
- minChars={1}
297
- debounce={150}
298
- renderOption={(opt) => (
299
- <>
300
- {opt.icon}
301
- <div className="min-w-0 flex-1">
302
- <p className="text-sm truncate">{opt.label}</p>
303
- {opt.subtitle && <p className="text-[10px] text-muted-foreground">{opt.subtitle}</p>}
304
- </div>
305
- </>
306
- )}
307
- />
308
-
309
- {selectedProduct && (
310
- <div className="rounded-lg bg-muted/30 px-4 py-3 flex items-center justify-between">
311
- <div>
312
- <p className="text-sm font-medium">{selectedProduct.name}</p>
313
- <p className="text-xs text-muted-foreground">
314
- {t('inventory.stock.currentStock')} <span className="font-medium">{selectedProduct.currentQuantity}</span>
315
- {selectedProduct.minQuantity > 0 && <> &middot; Min: {selectedProduct.minQuantity}</>}
316
- {selectedProduct.costPrice > 0 && <> &middot; Cost: {formatCurrency(selectedProduct.costPrice, currency)}</>}
317
- </p>
318
- </div>
319
- <span className="inline-flex items-center rounded-full px-2 py-0.5 text-[9px] font-medium bg-muted text-muted-foreground capitalize">{selectedProduct.productType}</span>
320
- </div>
321
- )}
322
-
323
- <div className="flex justify-end">
324
- <button
325
- onClick={() => setStep(2)}
326
- disabled={!canProceedStep1}
327
- className="inline-flex items-center gap-1.5 rounded-lg bg-primary border border-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 shadow-button-primary active:shadow-button-inset transition-colors disabled:opacity-50"
328
- >
329
- {t('inventory.stock.next')}
330
- </button>
331
- </div>
332
- </div>
333
- )}
334
-
335
- {/* Step 2: How much — Quantity + Cost + Location */}
336
- {step === 2 && (
337
- <div className="space-y-5">
338
- <div className="rounded-lg border bg-card shadow-sm p-5 space-y-4">
339
- <div className="grid gap-4 sm:grid-cols-3">
340
- <div>
341
- <label className="text-xs font-medium text-muted-foreground">{t('inventory.stock.quantity')}</label>
342
- <input
343
- type="number"
344
- min={0.01}
345
- step={0.01}
346
- value={quantity}
347
- onChange={(e) => setQuantity(Number(e.target.value) || 0)}
348
- autoFocus
349
- 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"
350
- />
351
- </div>
352
- <div>
353
- <label className="text-xs font-medium text-muted-foreground">{t('inventory.stock.unitCost')}</label>
354
- <div className="flex items-center mt-1 rounded-input border border-input bg-card shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] focus-within:ring-2 focus-within:ring-primary/20">
355
- <span className="pl-2.5 text-xs text-muted-foreground">{currency.symbol}</span>
356
- <input type="number" min={0} step={0.01} value={unitCost} onChange={(e) => setUnitCost(Number(e.target.value) || 0)} className="flex-1 bg-transparent px-2 py-2 text-sm outline-none" />
357
- </div>
358
- </div>
359
- <div>
360
- <label className="text-xs font-medium text-muted-foreground">{t('inventory.stock.total')}</label>
361
- <p className="mt-1 px-3 py-2 text-lg font-bold">{formatCurrency(totalCost, currency)}</p>
362
- </div>
363
- </div>
364
-
365
- {/* Location — radio-style selector with stock positions */}
366
- {modules.stockLocations && locations.length > 0 && (
367
- <div className="space-y-3">
368
- <div>
369
- <label className="text-xs font-medium text-muted-foreground">{needsDest ? t('inventory.stock.fromLocation') : t('inventory.stock.locationLabel')}</label>
370
- <div className="flex flex-wrap gap-2 mt-1.5">
371
- {sortedLocations.map((l) => {
372
- const qty = positionByLocation[l.id] ?? 0
373
- return (
374
- <button
375
- key={l.id}
376
- type="button"
377
- onClick={() => setLocationId(l.id)}
378
- className={cn(
379
- 'rounded-lg border-2 px-3 py-2 text-sm font-medium transition-all flex items-center gap-2',
380
- locationId === l.id
381
- ? 'border-primary bg-primary/5 text-primary'
382
- : 'border-transparent bg-muted/30 text-muted-foreground hover:bg-muted/50',
383
- )}
384
- >
385
- <span>{l.name}</span>
386
- {productId && (
387
- <span className={cn(
388
- 'inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold tabular-nums',
389
- qty > 0
390
- ? 'bg-success/10 text-success'
391
- : 'bg-muted text-muted-foreground',
392
- )}>
393
- {qty}
394
- </span>
395
- )}
396
- </button>
397
- )
398
- })}
399
- </div>
400
- </div>
401
- {needsDest && (
402
- <div>
403
- <label className="text-xs font-medium text-muted-foreground">{t('inventory.stock.toLocation')}</label>
404
- <div className="flex flex-wrap gap-2 mt-1.5">
405
- {sortedLocations.filter((l) => l.id !== locationId).map((l) => {
406
- const qty = positionByLocation[l.id] ?? 0
407
- return (
408
- <button
409
- key={l.id}
410
- type="button"
411
- onClick={() => setDestLocationId(l.id)}
412
- className={cn(
413
- 'rounded-lg border-2 px-3 py-2 text-sm font-medium transition-all flex items-center gap-2',
414
- destLocationId === l.id
415
- ? 'border-primary bg-primary/5 text-primary'
416
- : 'border-transparent bg-muted/30 text-muted-foreground hover:bg-muted/50',
417
- )}
418
- >
419
- <span>{l.name}</span>
420
- {productId && (
421
- <span className={cn(
422
- 'inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold tabular-nums',
423
- qty > 0
424
- ? 'bg-success/10 text-success'
425
- : 'bg-muted text-muted-foreground',
426
- )}>
427
- {qty}
428
- </span>
429
- )}
430
- </button>
431
- )
432
- })}
433
- </div>
434
- </div>
435
- )}
436
- </div>
437
- )}
438
-
439
- {/* Reason (required for adjustment/loss) */}
440
- {needsReason && (
441
- <div>
442
- <label className="text-xs font-medium text-muted-foreground">{t('inventory.stock.reason')}</label>
443
- <input type="text" value={reason} onChange={(e) => setReason(e.target.value)} placeholder={movementType === 'loss' ? t('inventory.stock.reasonLossPlaceholder') : t('inventory.stock.reasonAdjustPlaceholder')} 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" />
444
- </div>
445
- )}
446
- </div>
447
-
448
- <div className="flex justify-between">
449
- <button onClick={() => setStep(1)} className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted bg-card shadow-button active:shadow-button-inset transition-colors">{t('inventory.stock.back')}</button>
450
- <button onClick={() => setStep(3)} disabled={!canProceedStep2} className="inline-flex items-center gap-1.5 rounded-lg bg-primary border border-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 shadow-button-primary active:shadow-button-inset transition-colors disabled:opacity-50">{t('inventory.stock.next')}</button>
451
- </div>
452
- </div>
453
- )}
454
-
455
- {/* Step 3: Review + Optional details + Save */}
456
- {step === 3 && (
457
- <div className="space-y-5">
458
- {/* Summary card */}
459
- <div className="rounded-lg border bg-card shadow-sm p-5">
460
- <div className="flex items-center gap-3 mb-4">
461
- <div className={`flex h-10 w-10 items-center justify-center rounded-xl ${typeConfig.activeColor}`}>
462
- <typeConfig.icon className="h-5 w-5" />
463
- </div>
464
- <div>
465
- <p className="font-semibold">{productLabel}</p>
466
- <p className="text-xs text-muted-foreground capitalize">{movementType} &middot; {quantity} units &middot; {formatCurrency(totalCost, currency)}</p>
467
- </div>
468
- </div>
469
-
470
- <div className="grid gap-2 sm:grid-cols-3 text-xs border-t pt-3">
471
- <div><span className="text-muted-foreground">{t('inventory.stock.quantityLabel')}:</span> <span className="font-medium">{quantity}</span></div>
472
- <div><span className="text-muted-foreground">{t('inventory.stock.unitCostLabel')}:</span> <span className="font-medium">{formatCurrency(unitCost, currency)}</span></div>
473
- <div><span className="text-muted-foreground">{t('inventory.stock.total')}:</span> <span className="font-bold">{formatCurrency(totalCost, currency)}</span></div>
474
- </div>
475
- </div>
476
-
477
- {/* Optional details */}
478
- <div className="rounded-lg border bg-card shadow-sm p-5 space-y-4">
479
- <p className="text-xs font-medium text-muted-foreground">{t('inventory.stock.additionalDetails')}</p>
480
- <div className="grid gap-4 sm:grid-cols-2">
481
- <div>
482
- <label className="text-xs font-medium text-muted-foreground">{t('inventory.stock.documentNumber')}</label>
483
- <input type="text" value={documentNumber} onChange={(e) => setDocumentNumber(e.target.value)} placeholder={t('inventory.stock.documentPlaceholder')} 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" />
484
- </div>
485
- {modules.batchTracking && (
486
- <>
487
- <div>
488
- <label className="text-xs font-medium text-muted-foreground">{t('inventory.stock.batchNumber')}</label>
489
- <input type="text" value={batchNumber} onChange={(e) => setBatchNumber(e.target.value)} placeholder={t('inventory.stock.batchPlaceholder')} 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" />
490
- </div>
491
- <div>
492
- <label className="text-xs font-medium text-muted-foreground">{t('inventory.stock.expirationDate')}</label>
493
- <DatePicker value={expirationDate} onChange={setExpirationDate} className="mt-1" />
494
- </div>
495
- </>
496
- )}
497
- </div>
498
- <div>
499
- <label className="text-xs font-medium text-muted-foreground">{t('inventory.stock.notes')}</label>
500
- <textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} placeholder={t('inventory.stock.notesPlaceholder')} 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" />
501
- </div>
502
- </div>
503
-
504
- <div className="flex justify-between">
505
- <button onClick={() => setStep(2)} className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted bg-card shadow-button active:shadow-button-inset transition-colors">{t('inventory.stock.back')}</button>
506
- <button
507
- onClick={handleSave}
508
- disabled={saving}
509
- className="inline-flex items-center gap-1.5 rounded-lg bg-primary border border-primary px-5 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 shadow-button-primary active:shadow-button-inset transition-colors disabled:opacity-50"
510
- >
511
- <Check className="h-3.5 w-3.5" /> {saving ? t('inventory.stock.recording') : t('inventory.stock.recordMovement')}
512
- </button>
513
- </div>
514
- </div>
515
- )}
516
- </div>
517
- )
518
- }