@fayz-ai/plugin-inventory 0.8.0 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"ProductCrudForm.d.ts","sourceRoot":"","sources":["../../src/views/ProductCrudForm.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAgBzB,wBAAgB,eAAe,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;CAAE,qBAkF7F"}
1
+ {"version":3,"file":"ProductCrudForm.d.ts","sourceRoot":"","sources":["../../src/views/ProductCrudForm.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAgBzB,wBAAgB,eAAe,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAA;CAAE,qBAuF7F"}
@@ -1 +1 @@
1
- {"version":3,"file":"ProductListView.d.ts","sourceRoot":"","sources":["../../src/views/ProductListView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAuC,MAAM,OAAO,CAAA;AAW3D,wBAAgB,eAAe,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE;IACjD,KAAK,CAAC,EAAE,MAAM,IAAI,CAAA;IAClB,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;CAC9B,qBA0CA"}
1
+ {"version":3,"file":"ProductListView.d.ts","sourceRoot":"","sources":["../../src/views/ProductListView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAuC,MAAM,OAAO,CAAA;AAW3D,wBAAgB,eAAe,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE;IACjD,KAAK,CAAC,EAAE,MAAM,IAAI,CAAA;IAClB,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;CAC9B,qBA2CA"}
@@ -1 +1 @@
1
- {"version":3,"file":"RecipeFormView.d.ts","sourceRoot":"","sources":["../../src/views/RecipeFormView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAmB,MAAM,OAAO,CAAA;AAsBvC,wBAAgB,cAAc,CAAC,EAAE,OAAO,EAAE,EAAE;IAAE,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,qBAoN9E"}
1
+ {"version":3,"file":"RecipeFormView.d.ts","sourceRoot":"","sources":["../../src/views/RecipeFormView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAmB,MAAM,OAAO,CAAA;AAuBvC,wBAAgB,cAAc,CAAC,EAAE,OAAO,EAAE,EAAE;IAAE,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,qBAyN9E"}
@@ -1 +1 @@
1
- {"version":3,"file":"RecipesView.d.ts","sourceRoot":"","sources":["../../src/views/RecipesView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAoB,MAAM,OAAO,CAAA;AA4BxC,wBAAgB,WAAW,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;IAAC,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,qBA0EnG"}
1
+ {"version":3,"file":"RecipesView.d.ts","sourceRoot":"","sources":["../../src/views/RecipesView.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAoB,MAAM,OAAO,CAAA;AA6BxC,wBAAgB,WAAW,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;IAAC,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,qBA4EnG"}
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "fayz": {
4
4
  "status": "beta"
5
5
  },
6
- "version": "0.8.0",
6
+ "version": "0.8.3",
7
7
  "description": "Fayz SDK — plugin-inventory plugin",
8
8
  "type": "module",
9
9
  "main": "./dist/index.cjs",
@@ -29,9 +29,9 @@
29
29
  "lucide-react": "^0.400.0",
30
30
  "zustand": "^4.5.0",
31
31
  "@tanstack/react-table": "^8.20.0",
32
- "@fayz-ai/ui": "^0.8.0",
33
- "@fayz-ai/saas": "^0.8.0",
34
- "@fayz-ai/core": "^0.8.0"
32
+ "@fayz-ai/core": "^0.8.3",
33
+ "@fayz-ai/ui": "^0.8.3",
34
+ "@fayz-ai/saas": "^0.8.3"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/react": "^18.3.0",
@@ -4,6 +4,8 @@ import type { InventoryDataProvider } from './data/types'
4
4
  import type { InventoryUIState } from './store'
5
5
 
6
6
  export interface InventoryModules {
7
+ /** Product catalogue screens. Off when the host registers products elsewhere. */
8
+ products: boolean
7
9
  recipes: boolean
8
10
  stockLocations: boolean
9
11
  batchTracking: boolean
@@ -21,13 +21,13 @@ import { InventoryOnboarding } from './components/InventoryOnboarding'
21
21
  function buildNav(config: ResolvedInventoryConfig, view: string, navigate: (v: string) => void, t: (key: string) => string): ModuleNavItem[] {
22
22
  const items: ModuleNavItem[] = [
23
23
  { id: 'dashboard', label: t('inventory.nav.dashboard'), icon: 'BarChart3', active: view === 'dashboard', onClick: () => navigate('dashboard') },
24
- {
24
+ ...(config.modules.products ? [{
25
25
  id: 'products', label: t('inventory.nav.products'), icon: 'Package', active: view.startsWith('products'),
26
26
  children: [
27
27
  { id: 'products-new', label: t('inventory.nav.new'), active: view === 'products-new', onClick: () => navigate('products-new') },
28
28
  { id: 'products-list', label: t('inventory.nav.list'), active: view === 'products-list', onClick: () => navigate('products-list') },
29
29
  ],
30
- },
30
+ } as ModuleNavItem] : []),
31
31
  {
32
32
  id: 'stock', label: t('inventory.nav.stock'), icon: 'ArrowUpCircle',
33
33
  children: [
@@ -76,13 +76,15 @@ export function InventoryPage({ config, provider, store, registries }: {
76
76
 
77
77
  const quickActions = useMemo<PluginQuickAction[]>(() => {
78
78
  const actions: PluginQuickAction[] = [
79
- {
79
+ // Dropped with the catalogue module: offering "new product" while the
80
+ // screen is gone would dead-end the user.
81
+ ...(config.modules.products ? [{
80
82
  id: 'new-product',
81
83
  label: t('inventory.quickActions.newProduct'),
82
84
  icon: 'Package',
83
85
  description: t('inventory.quickActions.newProductDesc'),
84
86
  action: () => navigate('products-new'),
85
- },
87
+ } as PluginQuickAction] : []),
86
88
  {
87
89
  id: 'stock-entry',
88
90
  label: t('inventory.quickActions.stockEntry'),
@@ -128,9 +130,13 @@ export function InventoryPage({ config, provider, store, registries }: {
128
130
  }
129
131
 
130
132
  const renderView = createViewRouter([
131
- { id: 'products-list', render: () => <ProductListView onNew={() => navigate('products-new')} onEdit={(id) => navigate(`products-edit:${id}`)} /> },
132
- { id: 'products-new', render: () => <ProductCrudForm onSaved={() => navigate('products-list')} /> },
133
- { id: 'products-edit', render: ({ id }) => <ProductCrudForm editId={id!} onSaved={() => navigate('products-list')} /> },
133
+ // Routes go with the module, not just the nav entry: leaving them mounted
134
+ // keeps a second product form reachable by URL.
135
+ ...(config.modules.products ? [
136
+ { id: 'products-list', render: () => <ProductListView onNew={() => navigate('products-new')} onEdit={(id: string) => navigate(`products-edit:${id}`)} /> },
137
+ { id: 'products-new', render: () => <ProductCrudForm onSaved={() => navigate('products-list')} /> },
138
+ { id: 'products-edit', render: ({ id }: { id?: string }) => <ProductCrudForm editId={id!} onSaved={() => navigate('products-list')} /> },
139
+ ] : []),
134
140
  { id: 'stock-entry', render: () => <StockMovementView defaultType="entry" onSaved={() => navigate('stock-history')} /> },
135
141
  { id: 'stock-exit', render: () => <StockMovementView defaultType="exit" onSaved={() => navigate('stock-history')} /> },
136
142
  { id: 'stock-history', render: () => <MovementHistoryView onViewDetail={(id) => navigate(`stock-detail:${id}`)} /> },
@@ -1,25 +1,38 @@
1
1
  import React from 'react'
2
- import { SettingsGroup, ToggleRow } from '@fayz-ai/saas'
2
+ import { SettingsGroup, ToggleRow, useTenantPluginSettings } from '@fayz-ai/saas'
3
3
  import { useTranslation } from '@fayz-ai/core'
4
4
 
5
+ // Per-tenant defaults = the values that used to be hardcoded here. Persisted +
6
+ // re-hydrated per tenant; behavioural consumption is a follow-up.
7
+ const INVENTORY_DEFAULTS = {
8
+ lowStockAlerts: true,
9
+ requireReason: true,
10
+ autoDeduct: false,
11
+ requireSku: false,
12
+ allowNegative: false,
13
+ lowStockEmail: false,
14
+ expiryWarnings: true,
15
+ }
16
+
5
17
  export function InventoryGeneralSettings() {
6
18
  const t = useTranslation()
19
+ const s = useTenantPluginSettings('inventory', INVENTORY_DEFAULTS)
7
20
  return (
8
21
  <div className="space-y-4">
9
22
  <SettingsGroup title={t('inventory.settings.stockManagement')} description={t('inventory.settings.stockManagementDesc')}>
10
- <ToggleRow label={t('inventory.settings.lowStockAlerts')} description={t('inventory.settings.lowStockAlertsDesc')} checked={true} onChange={() => {}} />
11
- <ToggleRow label={t('inventory.settings.requireReason')} description={t('inventory.settings.requireReasonDesc')} checked={true} onChange={() => {}} />
12
- <ToggleRow label={t('inventory.settings.autoDeduct')} description={t('inventory.settings.autoDeductDesc')} checked={false} onChange={() => {}} />
23
+ <ToggleRow label={t('inventory.settings.lowStockAlerts')} description={t('inventory.settings.lowStockAlertsDesc')} checked={s.get('lowStockAlerts')} onChange={(v) => s.set('lowStockAlerts', v)} />
24
+ <ToggleRow label={t('inventory.settings.requireReason')} description={t('inventory.settings.requireReasonDesc')} checked={s.get('requireReason')} onChange={(v) => s.set('requireReason', v)} />
25
+ <ToggleRow label={t('inventory.settings.autoDeduct')} description={t('inventory.settings.autoDeductDesc')} checked={s.get('autoDeduct')} onChange={(v) => s.set('autoDeduct', v)} />
13
26
  </SettingsGroup>
14
27
 
15
28
  <SettingsGroup title={t('inventory.settings.products')} description={t('inventory.settings.productsDesc')}>
16
- <ToggleRow label={t('inventory.settings.requireSku')} description={t('inventory.settings.requireSkuDesc')} checked={false} onChange={() => {}} />
17
- <ToggleRow label={t('inventory.settings.allowNegative')} description={t('inventory.settings.allowNegativeDesc')} checked={false} onChange={() => {}} />
29
+ <ToggleRow label={t('inventory.settings.requireSku')} description={t('inventory.settings.requireSkuDesc')} checked={s.get('requireSku')} onChange={(v) => s.set('requireSku', v)} />
30
+ <ToggleRow label={t('inventory.settings.allowNegative')} description={t('inventory.settings.allowNegativeDesc')} checked={s.get('allowNegative')} onChange={(v) => s.set('allowNegative', v)} />
18
31
  </SettingsGroup>
19
32
 
20
33
  <SettingsGroup title={t('inventory.settings.notifications')} description={t('inventory.settings.notificationsDesc')}>
21
- <ToggleRow label={t('inventory.settings.lowStockEmail')} description={t('inventory.settings.lowStockEmailDesc')} checked={false} onChange={() => {}} />
22
- <ToggleRow label={t('inventory.settings.expiryWarnings')} description={t('inventory.settings.expiryWarningsDesc')} checked={true} onChange={() => {}} />
34
+ <ToggleRow label={t('inventory.settings.lowStockEmail')} description={t('inventory.settings.lowStockEmailDesc')} checked={s.get('lowStockEmail')} onChange={(v) => s.set('lowStockEmail', v)} />
35
+ <ToggleRow label={t('inventory.settings.expiryWarnings')} description={t('inventory.settings.expiryWarningsDesc')} checked={s.get('expiryWarnings')} onChange={(v) => s.set('expiryWarnings', v)} />
23
36
  </SettingsGroup>
24
37
  </div>
25
38
  )
package/src/index.ts CHANGED
@@ -37,6 +37,14 @@ export interface InventoryPluginOptions {
37
37
  recipes?: boolean
38
38
  stockLocations?: boolean
39
39
  batchTracking?: boolean
40
+ /**
41
+ * The product catalogue screens (list + form). Defaults on. Turn OFF when the
42
+ * host already owns product registration elsewhere — an e-commerce app
43
+ * manages products in the shop plugin, and two competing product CRUDs is
44
+ * how the same product ends up entered twice, differently.
45
+ * Stock entry/exit/history stay available either way.
46
+ */
47
+ products?: boolean
40
48
  }
41
49
  labels?: Partial<InventoryPluginLabels>
42
50
  productTypes?: Array<{ value: string; label: string }>
@@ -85,6 +93,7 @@ const DEFAULT_PRODUCT_TYPES = [
85
93
  function resolveConfig(options?: InventoryPluginOptions): ResolvedInventoryConfig {
86
94
  return {
87
95
  modules: {
96
+ products: options?.modules?.products !== false,
88
97
  recipes: options?.modules?.recipes !== false,
89
98
  stockLocations: options?.modules?.stockLocations !== false,
90
99
  batchTracking: options?.modules?.batchTracking ?? false,
@@ -126,6 +135,40 @@ export function createInventoryPlugin(options?: InventoryPluginOptions): PluginM
126
135
  { id: 'inventory', label: config.labels.pageTitle, group: config.labels.pageTitle },
127
136
  ...(config.modules.recipes ? [{ id: 'inventory.recipes', label: config.labels.recipes ?? 'Recipes', group: config.labels.pageTitle }] : []),
128
137
  ],
138
+ queryEntities: [
139
+ {
140
+ key: 'inventory:products',
141
+ writable: true,
142
+ entity: {
143
+ name: 'Product',
144
+ namePlural: 'Products',
145
+ icon: 'Package',
146
+ limitKey: 'products',
147
+ permission: { feature: 'inventory', action: 'read' },
148
+ fields: [
149
+ { key: 'name', label: 'Name', type: 'text', required: true, searchable: true },
150
+ { key: 'kind', label: 'Kind (sale/ingredient/asset)', type: 'text' },
151
+ { key: 'price', label: 'Price', type: 'number' },
152
+ { key: 'cost', label: 'Cost', type: 'number' },
153
+ { key: 'sku', label: 'SKU', type: 'text', searchable: true },
154
+ { key: 'isActive', label: 'Active', type: 'boolean' },
155
+ { key: 'createdAt', label: 'Created at', type: 'text' },
156
+ ],
157
+ data: {
158
+ table: 'products',
159
+ tenantScoped: true,
160
+ archetype: 'product',
161
+ archetypeKind: 'sale',
162
+ searchColumns: ['name', 'sku'],
163
+ defaults: { kind: 'sale', status: 'active', is_active: true },
164
+ },
165
+ },
166
+ },
167
+ ],
168
+ declaredLimits: [
169
+ { key: 'products', label: 'Products', table: 'products' },
170
+ ...(config.modules.recipes ? [{ key: 'recipes', label: config.labels.recipes ?? 'Recipes', table: 'plg_inventory_recipes' }] : []),
171
+ ],
129
172
  navigation: [
130
173
  {
131
174
  section: options?.navSection ?? 'main',
@@ -141,6 +184,10 @@ export function createInventoryPlugin(options?: InventoryPluginOptions): PluginM
141
184
  path: '/inventory',
142
185
  component: PageComponent,
143
186
  permission: { feature: 'inventory', action: 'read' as const },
187
+ // Products live on this hand-built page, not a CRUD route, so nothing
188
+ // would register them and a product the assistant just created would
189
+ // resolve to no page at all.
190
+ entityRoutes: ['product:sale', 'product'],
144
191
  },
145
192
  ],
146
193
  widgets: [],
@@ -1,7 +1,7 @@
1
1
  import React from 'react'
2
2
  import { SubpageHeader, toast } from '@fayz-ai/ui'
3
3
  import { useTranslation } from '@fayz-ai/core'
4
- import { CrudFormPage } from '@fayz-ai/saas'
4
+ import { CrudFormPage, useLimitGuard, invalidateLimit } from '@fayz-ai/saas'
5
5
  import { useInventoryConfig, useInventoryStore, useInventoryProvider } from '../InventoryContext'
6
6
  import type { ProductType } from '../types'
7
7
  import { buildProductEntity } from './productEntity'
@@ -19,6 +19,7 @@ export function ProductCrudForm({ editId, onSaved }: { editId?: string; onSaved?
19
19
  const { productTypes, currency } = useInventoryConfig()
20
20
  const provider = useInventoryProvider()
21
21
  const createProduct = useInventoryStore((s) => s.createProduct)
22
+ const guardProducts = useLimitGuard('products')
22
23
  const isEdit = !!editId
23
24
 
24
25
  const [initialData, setInitialData] = React.useState<Record<string, any> | null>(isEdit ? null : {})
@@ -72,7 +73,11 @@ export function ProductCrudForm({ editId, onSaved }: { editId?: string; onSaved?
72
73
  if (isEdit && editId) {
73
74
  await provider.updateProduct(editId, payload)
74
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
75
79
  await createProduct(payload)
80
+ invalidateLimit('products')
76
81
  }
77
82
  onSaved?.()
78
83
  }
@@ -52,6 +52,7 @@ export function ProductListView({ onNew, onEdit }: {
52
52
  onNew={onNew}
53
53
  addLabel={t('inventory.productList.newProduct')}
54
54
  onRowClick={(row) => onEdit?.((row as { id: string }).id)}
55
+ feature="inventory"
55
56
  />
56
57
  )
57
58
  }
@@ -5,6 +5,7 @@ import { toast } from '@fayz-ai/ui'
5
5
  import { SubpageHeader, useSaveBar } from '@fayz-ai/ui'
6
6
  import { useTranslation } from '@fayz-ai/core'
7
7
  import { SearchSelect } from '@fayz-ai/ui'
8
+ import { useLimitGuard, invalidateLimit } from '@fayz-ai/saas'
8
9
  import type { CreateRecipeIngredientInput } from '../types'
9
10
 
10
11
  interface FormIngredient {
@@ -24,6 +25,7 @@ export function RecipeFormView({ onSaved }: { onSaved?: (id?: string) => void })
24
25
  const t = useTranslation()
25
26
  const provider = useInventoryProvider()
26
27
  const createRecipe = useInventoryStore((s) => s.createRecipe)
28
+ const guardRecipes = useLimitGuard('recipes')
27
29
 
28
30
  const [name, setName] = useState('')
29
31
  const [description, setDescription] = useState('')
@@ -49,6 +51,9 @@ export function RecipeFormView({ onSaved }: { onSaved?: (id?: string) => void })
49
51
 
50
52
  async function handleSave() {
51
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
52
57
  setSaving(true)
53
58
  try {
54
59
  const recipe = await createRecipe({
@@ -66,6 +71,7 @@ export function RecipeFormView({ onSaved }: { onSaved?: (id?: string) => void })
66
71
  notes: i.notes || undefined,
67
72
  })),
68
73
  })
74
+ invalidateLimit('recipes')
69
75
  onSaved?.(recipe.id)
70
76
  } finally { setSaving(false) }
71
77
  }
@@ -3,6 +3,7 @@ import { BookOpen, Plus, Clock, Layers } from 'lucide-react'
3
3
  import { useInventoryStore } from '../InventoryContext'
4
4
  import { useTranslation } from '@fayz-ai/core'
5
5
  import { SubpageHeader } from '@fayz-ai/ui'
6
+ import { PermissionGate } from '@fayz-ai/saas'
6
7
 
7
8
  function RecipeSkeleton() {
8
9
  return (
@@ -40,9 +41,11 @@ export function RecipesView({ onNew, onView }: { onNew?: () => void; onView?: (i
40
41
  title={t('inventory.recipes.title')}
41
42
  subtitle={t('inventory.recipes.productionFormulas', { count: String(recipes.length) })}
42
43
  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>
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>
46
49
  )}
47
50
  />
48
51