@fayz-ai/plugin-inventory 0.9.4 → 0.10.0

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 (51) hide show
  1. package/dist/components/RecipeGapsPanel.d.ts +14 -0
  2. package/dist/components/RecipeGapsPanel.d.ts.map +1 -0
  3. package/dist/data/mock.d.ts.map +1 -1
  4. package/dist/data/supabase.d.ts.map +1 -1
  5. package/dist/data/tables.d.ts +6 -0
  6. package/dist/data/tables.d.ts.map +1 -1
  7. package/dist/data/types.d.ts +23 -1
  8. package/dist/data/types.d.ts.map +1 -1
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2459 -237
  12. package/dist/index.js.map +1 -1
  13. package/dist/lib/agent-tools.d.ts +4 -0
  14. package/dist/lib/agent-tools.d.ts.map +1 -0
  15. package/dist/lib/movement-vocabulary.d.ts +30 -0
  16. package/dist/lib/movement-vocabulary.d.ts.map +1 -0
  17. package/dist/lib/recipe-cost.d.ts +22 -0
  18. package/dist/lib/recipe-cost.d.ts.map +1 -0
  19. package/dist/lib/stock-count.d.ts +19 -0
  20. package/dist/lib/stock-count.d.ts.map +1 -0
  21. package/dist/locales/en.d.ts.map +1 -1
  22. package/dist/locales/pt-BR.d.ts.map +1 -1
  23. package/dist/migrations/index.d.ts +7 -0
  24. package/dist/migrations/index.d.ts.map +1 -1
  25. package/dist/types.d.ts +114 -0
  26. package/dist/types.d.ts.map +1 -1
  27. package/dist/views/InventoryPage.d.ts.map +1 -1
  28. package/dist/views/MovementHistoryView.d.ts +4 -1
  29. package/dist/views/MovementHistoryView.d.ts.map +1 -1
  30. package/dist/views/ProductCrudForm.d.ts.map +1 -1
  31. package/dist/views/ProductDetailView.d.ts +9 -0
  32. package/dist/views/ProductDetailView.d.ts.map +1 -0
  33. package/dist/views/ProductListView.d.ts +3 -2
  34. package/dist/views/ProductListView.d.ts.map +1 -1
  35. package/dist/views/ProductStockTab.d.ts +11 -0
  36. package/dist/views/ProductStockTab.d.ts.map +1 -0
  37. package/dist/views/RecipeDetailView.d.ts.map +1 -1
  38. package/dist/views/RecipeFormView.d.ts +3 -1
  39. package/dist/views/RecipeFormView.d.ts.map +1 -1
  40. package/dist/views/RecipesView.d.ts +3 -1
  41. package/dist/views/RecipesView.d.ts.map +1 -1
  42. package/dist/views/StockCountFormView.d.ts +7 -0
  43. package/dist/views/StockCountFormView.d.ts.map +1 -0
  44. package/dist/views/StockCountSessionView.d.ts +15 -0
  45. package/dist/views/StockCountSessionView.d.ts.map +1 -0
  46. package/dist/views/StockCountsView.d.ts +7 -0
  47. package/dist/views/StockCountsView.d.ts.map +1 -0
  48. package/dist/views/StockMovementView.d.ts.map +1 -1
  49. package/dist/views/productEntity.d.ts +4 -1
  50. package/dist/views/productEntity.d.ts.map +1 -1
  51. package/package.json +5 -4
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
- import React2, { useState, useMemo, useEffect } from 'react';
2
- import { toast, defineKpiWidget, defineCustomWidget, PageTransition, ModulePage, Button, SubpageHeader, SearchSelect, cn, DatePicker, ListView, useSaveBar, Breadcrumb, DataTable, DashboardCanvas, KpiCard, Card, CardHeader, CardTitle, CardContent } from '@fayz-ai/ui';
3
- import { createPluginContext, dedup, useModuleNavigation, createViewRouter, ModuleActionBar, useTenantPluginSettings, SettingsGroup, ToggleRow, CrudListView, useLimitGuard, CrudFormSkeleton, CrudFormPage, formatCurrency, PermissionGate, invalidateLimit } from '@fayz-ai/admin';
4
- import { registerTranslations, createSafeDataProvider, useTranslation, getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core';
1
+ import React3, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
2
+ import { toast, defineKpiWidget, defineCustomWidget, PageTransition, ModulePage, Button, SubpageHeader, SearchSelect, cn, DatePicker, ListView, ConfirmDialog, Switch, useSaveBar, DataTable, DashboardCanvas, KpiCard, Card, CardHeader, CardTitle, CardContent } from '@fayz-ai/ui';
3
+ import { createPluginContext, PluginSettingsPanel, dedup, useModuleNavigation, createViewRouter, ModuleActionBar, useTenantPluginSettings, SettingsGroup, ToggleRow, usePermissionOptional, CrudListView, useLimitGuard, CrudFormSkeleton, CrudFormPage, CrudDetailPage, formatCurrency, useAgentSurface, askAI, PermissionGate, QuickActionsButton, invalidateLimit } from '@fayz-ai/admin';
4
+ import { registerTranslations, createSafeDataProvider, useTranslation, useDataChanged, getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core';
5
5
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
6
- import { Package, Ruler, MapPin, Check, ChevronRight, ArrowDownRight, ArrowUpRight, RefreshCw, ArrowRightLeft, Trash2, Plus, BookOpen, Layers, Clock } from 'lucide-react';
6
+ import { Trash2, ArrowRightLeft, RefreshCw, ArrowUpRight, ArrowDownRight, Package, Ruler, MapPin, Check, ChevronRight, CircleSlash, CircleCheck, Plus, BookOpen, Layers, Clock, Coins, AlertTriangle, Building2, Undo2 } from 'lucide-react';
7
7
  import { createStore as createStore$1 } from 'zustand/vanilla';
8
8
 
9
9
  // src/index.ts
@@ -15,13 +15,231 @@ var useInventoryStore = ctx.useStore;
15
15
  function DashboardView() {
16
16
  return /* @__PURE__ */ jsx(DashboardCanvas, { surface: "plugin-home", domain: "inventory", showHeader: false, className: "space-y-6" });
17
17
  }
18
+
19
+ // src/data/tables.ts
20
+ var T = {
21
+ stockLocations: "plg_inventory_stock_locations",
22
+ stockMovements: "plg_inventory_stock_movements",
23
+ stockPositions: "plg_inventory_stock_positions",
24
+ recipes: "plg_inventory_recipes",
25
+ recipeIngredients: "plg_inventory_recipe_ingredients",
26
+ measurementUnits: "plg_inventory_measurement_units",
27
+ productCategories: "plg_inventory_product_categories",
28
+ productDetails: "plg_inventory_product_details",
29
+ recipeGroups: "plg_inventory_recipe_groups",
30
+ countSessions: "plg_inventory_count_sessions",
31
+ countItems: "plg_inventory_count_items"
32
+ };
33
+
34
+ // src/lib/movement-vocabulary.ts
35
+ var MOVEMENT_TYPES = [
36
+ { value: "entry", labelKey: "inventory.movementType.entry", icon: "ArrowDownToLine", effect: "increase" },
37
+ { value: "exit", labelKey: "inventory.movementType.exit", icon: "ArrowUpFromLine", effect: "decrease" },
38
+ { value: "adjustment", labelKey: "inventory.movementType.adjustment", icon: "Scale", effect: "set" },
39
+ { value: "transfer", labelKey: "inventory.movementType.transfer", icon: "ArrowLeftRight", effect: "move" },
40
+ { value: "loss", labelKey: "inventory.movementType.loss", icon: "TriangleAlert", effect: "decrease" }
41
+ ];
42
+ var MOVEMENT_TYPE_VALUES = MOVEMENT_TYPES.map((m) => m.value);
43
+ function movementTypeSpec(value) {
44
+ return MOVEMENT_TYPES.find((m) => m.value === value);
45
+ }
46
+ var LEGACY_PT_MOVEMENT_TYPE = {
47
+ entrada: "entry",
48
+ saida: "exit",
49
+ "sa\xEDda": "exit",
50
+ ajuste: "adjustment",
51
+ transferencia: "transfer",
52
+ "transfer\xEAncia": "transfer",
53
+ perda: "loss"
54
+ };
55
+ function normalizeMovementType(raw) {
56
+ const v = raw.trim().toLowerCase();
57
+ if (MOVEMENT_TYPE_VALUES.includes(v)) return v;
58
+ return LEGACY_PT_MOVEMENT_TYPE[v];
59
+ }
60
+ function TableSkeleton() {
61
+ return /* @__PURE__ */ jsx("div", { className: "rounded-card border p-4 space-y-2", children: [1, 2, 3].map((i) => /* @__PURE__ */ jsx("div", { className: "h-3.5 rounded bg-muted/30 animate-pulse" }, i)) });
62
+ }
63
+ function EmptyLine({ message }) {
64
+ return /* @__PURE__ */ jsx("div", { className: "rounded-card border p-8 text-center", children: /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: message }) });
65
+ }
66
+ function ProductStockTab({ item, onChanged }) {
67
+ const t2 = useTranslation();
68
+ const { currency } = useInventoryConfig();
69
+ const provider = useInventoryProvider();
70
+ const [positions, setPositions] = useState([]);
71
+ const [movements, setMovements] = useState([]);
72
+ const [loading, setLoading] = useState(true);
73
+ const [confirming, setConfirming] = useState(false);
74
+ const [undoing, setUndoing] = useState(false);
75
+ const load = useCallback(async () => {
76
+ setLoading(true);
77
+ try {
78
+ const [pos, history] = await Promise.all([
79
+ provider.getPositions(item.id),
80
+ provider.getMovements({ productId: item.id, pageSize: 100 })
81
+ ]);
82
+ setPositions(pos);
83
+ setMovements(history.data);
84
+ } finally {
85
+ setLoading(false);
86
+ }
87
+ }, [item.id, provider]);
88
+ useEffect(() => {
89
+ void load();
90
+ }, [load]);
91
+ const ordered = useMemo(
92
+ () => [...movements].sort((a, b) => (b.createdAt ?? b.movementDate).localeCompare(a.createdAt ?? a.movementDate)),
93
+ [movements]
94
+ );
95
+ const lastMovement = ordered[0];
96
+ const onHand = useMemo(() => positions.reduce((sum, p) => sum + (p.quantity ?? 0), 0), [positions]);
97
+ const movementLabel = (m) => {
98
+ const spec = movementTypeSpec(m.movementType);
99
+ return spec ? t2(spec.labelKey) : m.movementType;
100
+ };
101
+ async function handleUndo() {
102
+ if (!lastMovement) return;
103
+ setUndoing(true);
104
+ try {
105
+ await provider.reverseMovement(lastMovement.id);
106
+ toast.success(t2("inventory.productDetail.undone"));
107
+ await load();
108
+ onChanged?.();
109
+ } catch (err) {
110
+ toast.error(t2("inventory.productDetail.undoFailed"), { description: err?.message });
111
+ } finally {
112
+ setUndoing(false);
113
+ setConfirming(false);
114
+ }
115
+ }
116
+ const positionColumns = useMemo(() => [
117
+ {
118
+ accessorKey: "stockLocationName",
119
+ header: t2("inventory.stock.locationLabel"),
120
+ cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "font-medium", children: getValue() ?? "\u2014" })
121
+ },
122
+ {
123
+ accessorKey: "batchNumber",
124
+ header: t2("inventory.stock.batchNumber"),
125
+ cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: getValue() || "\u2014" })
126
+ },
127
+ {
128
+ accessorKey: "expirationDate",
129
+ header: t2("inventory.stock.expirationDate"),
130
+ cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: getValue() || "\u2014" })
131
+ },
132
+ {
133
+ accessorKey: "unitCost",
134
+ header: () => /* @__PURE__ */ jsx("span", { className: "block text-right", children: t2("inventory.stock.unitCostLabel") }),
135
+ cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "block text-right text-muted-foreground", children: formatCurrency(Number(getValue()) || 0, currency) })
136
+ },
137
+ {
138
+ accessorKey: "quantity",
139
+ header: () => /* @__PURE__ */ jsx("span", { className: "block text-right", children: t2("inventory.stock.quantityLabel") }),
140
+ cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "block text-right font-medium tabular-nums", children: getValue() })
141
+ },
142
+ {
143
+ id: "total",
144
+ header: () => /* @__PURE__ */ jsx("span", { className: "block text-right", children: t2("inventory.stock.totalCost") }),
145
+ cell: ({ row }) => /* @__PURE__ */ jsx("span", { className: "block text-right tabular-nums", children: formatCurrency((row.original.quantity ?? 0) * (row.original.unitCost ?? 0), currency) })
146
+ }
147
+ ], [currency, t2]);
148
+ const movementColumns = useMemo(() => [
149
+ {
150
+ accessorKey: "movementDate",
151
+ header: t2("inventory.history.columnDate"),
152
+ cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: getValue() })
153
+ },
154
+ {
155
+ accessorKey: "movementType",
156
+ header: t2("inventory.history.columnType"),
157
+ cell: ({ row }) => /* @__PURE__ */ jsx("span", { className: "text-xs", children: movementLabel(row.original) })
158
+ },
159
+ {
160
+ accessorKey: "stockLocationName",
161
+ header: t2("inventory.history.columnLocation"),
162
+ cell: ({ row }) => /* @__PURE__ */ jsxs("span", { className: "text-xs text-muted-foreground", children: [
163
+ row.original.stockLocationName ?? "\u2014",
164
+ row.original.movementType === "transfer" && row.original.destinationLocationName && /* @__PURE__ */ jsxs("span", { children: [
165
+ " \u2192 ",
166
+ row.original.destinationLocationName
167
+ ] })
168
+ ] })
169
+ },
170
+ {
171
+ accessorKey: "quantity",
172
+ header: () => /* @__PURE__ */ jsx("span", { className: "block text-right", children: t2("inventory.history.columnQty") }),
173
+ cell: ({ row }) => /* @__PURE__ */ jsxs("span", { className: "block text-right tabular-nums", children: [
174
+ row.original.movementType === "entry" ? "+" : "-",
175
+ row.original.quantity
176
+ ] })
177
+ },
178
+ {
179
+ accessorKey: "totalCost",
180
+ header: () => /* @__PURE__ */ jsx("span", { className: "block text-right", children: t2("inventory.history.columnTotal") }),
181
+ cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "block text-right text-muted-foreground", children: formatCurrency(Number(getValue()) || 0, currency) })
182
+ }
183
+ ], [currency, t2]);
184
+ if (loading) return /* @__PURE__ */ jsx(TableSkeleton, {});
185
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
186
+ /* @__PURE__ */ jsxs("section", { className: "space-y-2", children: [
187
+ /* @__PURE__ */ jsxs("div", { className: "flex items-baseline justify-between", children: [
188
+ /* @__PURE__ */ jsx("h3", { className: "text-sm font-semibold text-foreground", children: t2("inventory.productDetail.positionsTitle") }),
189
+ /* @__PURE__ */ jsxs("p", { className: "text-xs text-muted-foreground", children: [
190
+ t2("inventory.productDetail.onHand"),
191
+ " ",
192
+ /* @__PURE__ */ jsx("span", { className: "font-medium text-foreground tabular-nums", children: onHand })
193
+ ] })
194
+ ] }),
195
+ positions.length === 0 ? /* @__PURE__ */ jsx(EmptyLine, { message: t2("inventory.productDetail.noPositions") }) : /* @__PURE__ */ jsx(DataTable, { columns: positionColumns, data: positions, variant: "card" })
196
+ ] }),
197
+ /* @__PURE__ */ jsxs("section", { className: "space-y-2", children: [
198
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
199
+ /* @__PURE__ */ jsx("h3", { className: "text-sm font-semibold text-foreground", children: t2("inventory.productDetail.historyTitle") }),
200
+ lastMovement && /* @__PURE__ */ jsxs(
201
+ Button,
202
+ {
203
+ variant: "outline",
204
+ size: "sm",
205
+ className: "text-destructive hover:text-destructive",
206
+ onClick: () => setConfirming(true),
207
+ children: [
208
+ /* @__PURE__ */ jsx(Undo2, { className: "mr-1.5 h-3.5 w-3.5" }),
209
+ t2("inventory.productDetail.undoLast")
210
+ ]
211
+ }
212
+ )
213
+ ] }),
214
+ ordered.length === 0 ? /* @__PURE__ */ jsx(EmptyLine, { message: t2("inventory.history.noMovements") }) : /* @__PURE__ */ jsx(DataTable, { columns: movementColumns, data: ordered, variant: "card" })
215
+ ] }),
216
+ /* @__PURE__ */ jsx(
217
+ ConfirmDialog,
218
+ {
219
+ open: confirming,
220
+ variant: "destructive",
221
+ title: t2("inventory.productDetail.undoTitle"),
222
+ description: lastMovement ? t2("inventory.productDetail.undoDescription", {
223
+ type: movementLabel(lastMovement),
224
+ quantity: String(lastMovement.quantity),
225
+ date: lastMovement.movementDate
226
+ }) : void 0,
227
+ confirmLabel: t2("inventory.productDetail.undoConfirm"),
228
+ cancelLabel: t2("inventory.productForm.cancel"),
229
+ loading: undoing,
230
+ onConfirm: () => void handleUndo(),
231
+ onCancel: () => setConfirming(false)
232
+ }
233
+ )
234
+ ] });
235
+ }
18
236
  var TYPE_DESCRIPTION_KEYS = {
19
237
  ingredient: "inventory.productForm.typeIngredient",
20
238
  sale: "inventory.productForm.typeSale",
21
239
  intermediate: "inventory.productForm.typeIntermediate",
22
240
  asset: "inventory.productForm.typeAsset"
23
241
  };
24
- function buildProductEntity(t2, productTypes, currency) {
242
+ function buildProductEntity(t2, productTypes, currency, onStockChanged) {
25
243
  const typeOptions = productTypes.map((pt) => ({
26
244
  label: pt.label,
27
245
  value: pt.value,
@@ -41,7 +259,10 @@ function buildProductEntity(t2, productTypes, currency) {
41
259
  showInTable: true,
42
260
  sortable: true,
43
261
  renderCell: (_v, row) => /* @__PURE__ */ jsxs("div", { children: [
44
- /* @__PURE__ */ jsx("p", { className: "font-medium", children: row.name }),
262
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
263
+ /* @__PURE__ */ jsx("p", { className: `font-medium ${row.isActive === false ? "text-muted-foreground" : ""}`, children: row.name }),
264
+ row.isActive === false && /* @__PURE__ */ jsx("span", { className: "inline-flex items-center rounded-full bg-muted px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground", children: t2("inventory.productList.inactive") })
265
+ ] }),
45
266
  row.sku && /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: row.sku })
46
267
  ] })
47
268
  },
@@ -111,7 +332,47 @@ function buildProductEntity(t2, productTypes, currency) {
111
332
  },
112
333
  // — Stock Levels (form only) —
113
334
  { key: "minQuantity", label: t2("inventory.productForm.minQuantity"), type: "number", group: "stock", min: 0, placeholder: "0", hint: t2("inventory.productForm.minQuantityHint"), showInTable: false },
114
- { key: "maxQuantity", label: t2("inventory.productForm.maxQuantity"), type: "number", group: "stock", min: 0, placeholder: t2("inventory.productForm.optional"), hint: t2("inventory.productForm.maxQuantityHint"), showInTable: false }
335
+ { key: "maxQuantity", label: t2("inventory.productForm.maxQuantity"), type: "number", group: "stock", min: 0, placeholder: t2("inventory.productForm.optional"), hint: t2("inventory.productForm.maxQuantityHint"), showInTable: false },
336
+ // — Organisation (form only) — the four links that used to be accepted by
337
+ // CreateProductInput and then silently dropped; they persist in
338
+ // plg_inventory_product_details since migration 005.
339
+ {
340
+ key: "categoryId",
341
+ label: t2("inventory.productForm.category"),
342
+ type: "relation",
343
+ group: "organization",
344
+ relation: { table: T.productCategories, labelField: "name" },
345
+ placeholder: t2("inventory.productForm.optional"),
346
+ showInTable: false
347
+ },
348
+ {
349
+ key: "measurementUnitId",
350
+ label: t2("inventory.productForm.measurementUnit"),
351
+ type: "relation",
352
+ group: "organization",
353
+ relation: { table: T.measurementUnits, labelField: "name" },
354
+ hint: t2("inventory.productForm.measurementUnitHint"),
355
+ placeholder: t2("inventory.productForm.optional"),
356
+ showInTable: false
357
+ },
358
+ {
359
+ key: "supplierId",
360
+ label: t2("inventory.productForm.supplier"),
361
+ type: "relation",
362
+ group: "organization",
363
+ relation: { table: "people", labelField: "name", filter: { kind: "supplier" } },
364
+ placeholder: t2("inventory.productForm.optional"),
365
+ showInTable: false
366
+ },
367
+ {
368
+ key: "defaultLocationId",
369
+ label: t2("inventory.productForm.defaultLocation"),
370
+ type: "relation",
371
+ group: "organization",
372
+ relation: { table: T.stockLocations, labelField: "name" },
373
+ placeholder: t2("inventory.productForm.optional"),
374
+ showInTable: false
375
+ }
115
376
  ];
116
377
  return {
117
378
  name: t2("inventory.stock.product"),
@@ -123,13 +384,25 @@ function buildProductEntity(t2, productTypes, currency) {
123
384
  { id: "general", label: t2("inventory.productForm.generalInfo"), columns: 2, imageSlot: true },
124
385
  { id: "classification", label: t2("inventory.productForm.classification"), description: t2("inventory.productForm.classificationDesc"), columns: 1 },
125
386
  { id: "pricing", label: t2("inventory.productForm.pricing"), columns: 3 },
126
- { id: "stock", label: t2("inventory.productForm.stockLevels"), description: t2("inventory.productForm.stockLevelsDesc"), columns: 2 }
387
+ { id: "stock", label: t2("inventory.productForm.stockLevels"), description: t2("inventory.productForm.stockLevelsDesc"), columns: 2 },
388
+ { id: "organization", label: t2("inventory.productForm.organization"), description: t2("inventory.productForm.organizationDesc"), columns: 2 }
127
389
  ],
128
- facets: [{ field: "productType", allLabel: t2("crud.list.allFacet") }]
390
+ facets: [{ field: "productType", allLabel: t2("crud.list.allFacet") }],
391
+ detailTabs: [
392
+ {
393
+ id: "stock",
394
+ label: t2("inventory.productDetail.stockTab"),
395
+ icon: "Warehouse",
396
+ component: ProductStockTab,
397
+ props: { onChanged: onStockChanged }
398
+ }
399
+ ]
129
400
  };
130
401
  }
131
- function ProductListView({ onNew, onEdit }) {
402
+ function ProductListView({ onNew, onOpen }) {
132
403
  const t2 = useTranslation();
404
+ const can = usePermissionOptional();
405
+ const provider = useInventoryProvider();
133
406
  const { currency, productTypes } = useInventoryConfig();
134
407
  const products = useInventoryStore((s) => s.products);
135
408
  const productsTotal = useInventoryStore((s) => s.productsTotal);
@@ -137,22 +410,83 @@ function ProductListView({ onNew, onEdit }) {
137
410
  const fetchProducts = useInventoryStore((s) => s.fetchProducts);
138
411
  const [search, setSearch] = useState("");
139
412
  const [typeFilter, setTypeFilter] = useState();
413
+ const [activeFilter, setActiveFilter] = useState();
140
414
  const [loadedOnce, setLoadedOnce] = useState(false);
415
+ const [bulkBusy, setBulkBusy] = useState(false);
416
+ const query = useMemo(() => ({
417
+ productType: typeFilter,
418
+ search: search || void 0,
419
+ isActive: activeFilter === void 0 ? void 0 : activeFilter === "true"
420
+ }), [typeFilter, search, activeFilter]);
141
421
  useEffect(() => {
142
422
  let active = true;
143
- Promise.resolve(fetchProducts({ productType: typeFilter, search: search || void 0 })).finally(() => {
423
+ Promise.resolve(fetchProducts(query)).finally(() => {
144
424
  if (active) setLoadedOnce(true);
145
425
  });
146
426
  return () => {
147
427
  active = false;
148
428
  };
149
- }, [typeFilter, search]);
429
+ }, [query]);
150
430
  const entity = useMemo(() => buildProductEntity(t2, productTypes, currency), [t2, productTypes, currency]);
151
- const facets = useMemo(() => [{
152
- field: "productType",
153
- allLabel: t2("crud.list.allFacet"),
154
- options: productTypes.map((p) => ({ value: p.value, label: p.label }))
155
- }], [productTypes, t2]);
431
+ const facets = useMemo(() => [
432
+ {
433
+ field: "productType",
434
+ allLabel: t2("crud.list.allFacet"),
435
+ options: productTypes.map((p) => ({ value: p.value, label: p.label }))
436
+ },
437
+ {
438
+ field: "isActive",
439
+ label: t2("inventory.productList.filterStatus"),
440
+ allLabel: t2("inventory.productList.filterAllStatuses"),
441
+ options: [
442
+ { value: "true", label: t2("inventory.productList.filterActive") },
443
+ { value: "false", label: t2("inventory.productList.filterInactive") }
444
+ ]
445
+ }
446
+ ], [productTypes, t2]);
447
+ const runBulk = useCallback(async (rows, isActive) => {
448
+ const targets = rows.filter((p) => (p.isActive ?? true) !== isActive);
449
+ if (targets.length === 0) {
450
+ toast.error(t2("inventory.productList.bulkNothingToDo"));
451
+ return;
452
+ }
453
+ setBulkBusy(true);
454
+ let done = 0;
455
+ let failed = 0;
456
+ for (const product of targets) {
457
+ try {
458
+ await provider.updateProduct(product.id, { isActive });
459
+ done++;
460
+ } catch (err) {
461
+ failed++;
462
+ console.warn("[inventory] updateProduct failed", product.id, err);
463
+ }
464
+ }
465
+ setBulkBusy(false);
466
+ await fetchProducts(query);
467
+ if (done > 0) toast.success(t2(isActive ? "inventory.productList.bulkActivated" : "inventory.productList.bulkDeactivated", { count: String(done) }));
468
+ if (failed > 0) toast.error(t2("inventory.productList.bulkFailed", { count: String(failed) }));
469
+ }, [provider, fetchProducts, query, t2]);
470
+ const bulkActions = useMemo(() => {
471
+ if (!can("inventory", "edit")) return void 0;
472
+ return [
473
+ {
474
+ id: "deactivate",
475
+ label: t2("inventory.productList.bulkDeactivate"),
476
+ icon: CircleSlash,
477
+ disabled: bulkBusy,
478
+ onSelect: (rows) => void runBulk(rows, false)
479
+ },
480
+ {
481
+ id: "activate",
482
+ label: t2("inventory.productList.bulkActivate"),
483
+ icon: CircleCheck,
484
+ overflow: true,
485
+ disabled: bulkBusy,
486
+ onSelect: (rows) => void runBulk(rows, true)
487
+ }
488
+ ];
489
+ }, [can, bulkBusy, runBulk, t2]);
156
490
  return /* @__PURE__ */ jsx(
157
491
  CrudListView,
158
492
  {
@@ -163,11 +497,26 @@ function ProductListView({ onNew, onEdit }) {
163
497
  onSearchChange: setSearch,
164
498
  searchPlaceholder: t2("inventory.productList.searchPlaceholder"),
165
499
  facets,
166
- activeFilters: { productType: typeFilter },
167
- onFacetChange: (_field, value) => setTypeFilter(value),
500
+ activeFilters: { productType: typeFilter, isActive: activeFilter },
501
+ onFacetChange: (field, value) => {
502
+ if (field === "isActive") setActiveFilter(value);
503
+ else setTypeFilter(value);
504
+ },
505
+ bulkActions,
168
506
  onNew,
507
+ aiCreate: {
508
+ hint: t2("inventory.productList.askPlaceholder"),
509
+ description: t2("inventory.productList.aiHint"),
510
+ intro: {
511
+ role: "operations",
512
+ persona: t2("inventory.productList.askPersona"),
513
+ pitch: t2("inventory.productList.askPitch"),
514
+ description: t2("inventory.productList.askDescription"),
515
+ suggestions: [0, 1, 2].map((i) => ({ label: t2(`inventory.productList.askSuggestion.${i}`) }))
516
+ }
517
+ },
169
518
  addLabel: t2("inventory.productList.newProduct"),
170
- onRowClick: (row) => onEdit?.(row.id),
519
+ onRowClick: (row) => onOpen?.(row.id),
171
520
  feature: "inventory"
172
521
  }
173
522
  );
@@ -179,9 +528,9 @@ function ProductCrudForm({ editId, onSaved }) {
179
528
  const createProduct = useInventoryStore((s) => s.createProduct);
180
529
  const guardProducts = useLimitGuard("products");
181
530
  const isEdit = !!editId;
182
- const [initialData, setInitialData] = React2.useState(isEdit ? null : {});
183
- const [headerName, setHeaderName] = React2.useState("");
184
- React2.useEffect(() => {
531
+ const [initialData, setInitialData] = React3.useState(isEdit ? null : {});
532
+ const [headerName, setHeaderName] = React3.useState("");
533
+ React3.useEffect(() => {
185
534
  if (!editId) return;
186
535
  let cancelled = false;
187
536
  (async () => {
@@ -199,7 +548,11 @@ function ProductCrudForm({ editId, onSaved }) {
199
548
  costPrice: p.costPrice,
200
549
  salePrice: p.salePrice ?? 0,
201
550
  minQuantity: p.minQuantity,
202
- maxQuantity: p.maxQuantity ?? 0
551
+ maxQuantity: p.maxQuantity ?? 0,
552
+ categoryId: p.categoryId ?? "",
553
+ measurementUnitId: p.measurementUnitId ?? "",
554
+ supplierId: p.supplierId ?? "",
555
+ defaultLocationId: p.defaultLocationId ?? ""
203
556
  } : {}
204
557
  );
205
558
  })();
@@ -207,7 +560,7 @@ function ProductCrudForm({ editId, onSaved }) {
207
560
  cancelled = true;
208
561
  };
209
562
  }, [editId]);
210
- const entity = React2.useMemo(() => buildProductEntity(t2, productTypes, currency), [t2, productTypes, currency]);
563
+ const entity = React3.useMemo(() => buildProductEntity(t2, productTypes, currency), [t2, productTypes, currency]);
211
564
  async function save(values) {
212
565
  const name = String(values.name ?? "").trim();
213
566
  if (!name) {
@@ -224,7 +577,13 @@ function ProductCrudForm({ editId, onSaved }) {
224
577
  salePrice: Number(values.salePrice) || void 0,
225
578
  minQuantity: Number(values.minQuantity) || 0,
226
579
  maxQuantity: Number(values.maxQuantity) || void 0,
227
- description: values.description || void 0
580
+ description: values.description || void 0,
581
+ // Always sent, even when empty: an empty string clears the link, and
582
+ // `undefined` would leave the previous value in place on edit.
583
+ categoryId: values.categoryId ?? "",
584
+ measurementUnitId: values.measurementUnitId ?? "",
585
+ supplierId: values.supplierId ?? "",
586
+ defaultLocationId: values.defaultLocationId ?? ""
228
587
  };
229
588
  if (isEdit && editId) {
230
589
  await provider.updateProduct(editId, payload);
@@ -254,20 +613,87 @@ function ProductCrudForm({ editId, onSaved }) {
254
613
  )
255
614
  ] });
256
615
  }
257
- var MOVEMENT_TYPES = [
258
- { 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" },
259
- { 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" },
260
- { 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" },
261
- { 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" },
262
- { 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" }
263
- ];
616
+ function DetailSkeleton() {
617
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-5", children: [
618
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-4", children: [
619
+ /* @__PURE__ */ jsx("div", { className: "h-20 w-20 rounded-2xl bg-muted/40 animate-pulse" }),
620
+ /* @__PURE__ */ jsxs("div", { className: "space-y-2 flex-1", children: [
621
+ /* @__PURE__ */ jsx("div", { className: "h-6 w-56 rounded bg-muted/40 animate-pulse" }),
622
+ /* @__PURE__ */ jsx("div", { className: "h-3 w-32 rounded bg-muted/30 animate-pulse" })
623
+ ] })
624
+ ] }),
625
+ /* @__PURE__ */ jsx("div", { className: "rounded-card border p-4 space-y-2", children: [1, 2, 3, 4].map((i) => /* @__PURE__ */ jsx("div", { className: "h-3.5 rounded bg-muted/30 animate-pulse" }, i)) })
626
+ ] });
627
+ }
628
+ function ProductDetailView({ productId, initialTab, onBack, onEdit }) {
629
+ const t2 = useTranslation();
630
+ const { currency, productTypes } = useInventoryConfig();
631
+ const provider = useInventoryProvider();
632
+ const [product, setProduct] = useState(null);
633
+ const [loading, setLoading] = useState(true);
634
+ const [reloadKey, setReloadKey] = useState(0);
635
+ const reload = useCallback(() => setReloadKey((k) => k + 1), []);
636
+ useEffect(() => {
637
+ let active = true;
638
+ setLoading(true);
639
+ provider.getProductById(productId).then((p) => {
640
+ if (active) setProduct(p);
641
+ }).finally(() => {
642
+ if (active) setLoading(false);
643
+ });
644
+ return () => {
645
+ active = false;
646
+ };
647
+ }, [productId, provider, reloadKey]);
648
+ const entity = useMemo(
649
+ () => buildProductEntity(t2, productTypes, currency, reload),
650
+ [t2, productTypes, currency, reload]
651
+ );
652
+ if (loading) {
653
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
654
+ /* @__PURE__ */ jsx(SubpageHeader, { title: t2("inventory.nav.products"), onBack }),
655
+ /* @__PURE__ */ jsx(DetailSkeleton, {})
656
+ ] });
657
+ }
658
+ if (!product) {
659
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
660
+ /* @__PURE__ */ jsx(SubpageHeader, { title: t2("inventory.nav.products"), onBack }),
661
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center justify-center py-16 text-center rounded-lg border-2 border-dashed border-muted", children: [
662
+ /* @__PURE__ */ jsx(Package, { className: "h-8 w-8 text-muted-foreground/30 mb-2" }),
663
+ /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: t2("inventory.productDetail.notFound") }),
664
+ /* @__PURE__ */ jsx("button", { onClick: onBack, className: "text-xs text-primary hover:underline mt-1", children: t2("inventory.productDetail.backToList") })
665
+ ] })
666
+ ] });
667
+ }
668
+ return /* @__PURE__ */ jsx(
669
+ CrudDetailPage,
670
+ {
671
+ entityDef: entity,
672
+ item: product,
673
+ namePlural: t2("inventory.nav.products"),
674
+ basePath: "/inventory/products",
675
+ initialTab,
676
+ onBack,
677
+ onEdit,
678
+ feature: "inventory"
679
+ }
680
+ );
681
+ }
682
+ var MOVEMENT_TYPE_PRESENTATION = {
683
+ 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" },
684
+ 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" },
685
+ 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" },
686
+ 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" },
687
+ 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" }
688
+ };
689
+ var MOVEMENT_TYPES2 = MOVEMENT_TYPE_VALUES.map((value) => ({ value, ...MOVEMENT_TYPE_PRESENTATION[value] }));
264
690
  function MovementDetail({ movement, currency, onClose }) {
265
691
  const t2 = useTranslation();
266
- const typeConfig = MOVEMENT_TYPES.find((mt) => mt.value === movement.movementType);
692
+ const typeConfig = MOVEMENT_TYPES2.find((mt) => mt.value === movement.movementType);
267
693
  const TypeIcon = typeConfig?.icon ?? Package;
268
694
  return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
269
695
  /* @__PURE__ */ jsx(SubpageHeader, { title: movement.productName ?? `#${movement.id.slice(0, 8)}`, onBack: onClose, parentLabel: t2("inventory.nav.stock") }),
270
- /* @__PURE__ */ jsxs("div", { className: "rounded-lg border bg-card shadow-sm p-5 space-y-4", children: [
696
+ /* @__PURE__ */ jsxs("div", { className: "rounded-card border bg-card shadow-sm p-5 space-y-4", children: [
271
697
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
272
698
  /* @__PURE__ */ jsx("div", { className: `flex h-10 w-10 items-center justify-center rounded-xl ${typeConfig?.activeColor ?? "bg-muted"}`, children: /* @__PURE__ */ jsx(TypeIcon, { className: "h-5 w-5" }) }),
273
699
  /* @__PURE__ */ jsxs("div", { children: [
@@ -353,12 +779,19 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
353
779
  const [notes, setNotes] = useState("");
354
780
  const [batchNumber, setBatchNumber] = useState("");
355
781
  const [expirationDate, setExpirationDate] = useState("");
782
+ const [suppliers, setSuppliers] = useState([]);
783
+ const [supplierId, setSupplierId] = useState("");
784
+ const [supplierLabel, setSupplierLabel] = useState("");
356
785
  const [saving, setSaving] = useState(false);
357
786
  const [savedMovement, setSavedMovement] = useState(null);
358
787
  useEffect(() => {
359
788
  fetchProducts({});
360
789
  fetchLocations();
361
790
  }, []);
791
+ useEffect(() => {
792
+ if (movementType !== "entry" || suppliers.length > 0) return;
793
+ provider.getSuppliers().then(setSuppliers).catch(() => setSuppliers([]));
794
+ }, [movementType]);
362
795
  const positionByLocation = useMemo(() => {
363
796
  const map = {};
364
797
  for (const pos of positions) {
@@ -379,7 +812,7 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
379
812
  }
380
813
  const selectedProduct = products.find((p) => p.id === productId);
381
814
  const totalCost = quantity * unitCost;
382
- const typeConfig = MOVEMENT_TYPES.find((t3) => t3.value === movementType);
815
+ const typeConfig = MOVEMENT_TYPES2.find((t3) => t3.value === movementType);
383
816
  const needsReason = movementType === "adjustment" || movementType === "loss";
384
817
  const needsDest = movementType === "transfer";
385
818
  function searchProducts(query) {
@@ -405,6 +838,15 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
405
838
  setPositions([]);
406
839
  }
407
840
  }
841
+ function searchSuppliers(query) {
842
+ const q = query.toLowerCase();
843
+ return suppliers.filter((s) => s.name.toLowerCase().includes(q) || s.documentNumber?.toLowerCase().includes(q)).slice(0, 20).map((s) => ({
844
+ id: s.id,
845
+ label: s.name,
846
+ subtitle: s.documentNumber ?? s.phone,
847
+ icon: /* @__PURE__ */ jsx(Building2, { className: "h-4 w-4 text-muted-foreground shrink-0" })
848
+ }));
849
+ }
408
850
  const canProceedStep1 = !!productId;
409
851
  const canProceedStep2 = quantity > 0 && (!needsReason || reason.trim()) && (!needsDest || destLocationId);
410
852
  const title = defaultType === "entry" ? t2("inventory.stock.entry") : defaultType === "exit" ? t2("inventory.stock.exit") : t2("inventory.stock.movement");
@@ -423,7 +865,8 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
423
865
  documentNumber: documentNumber || void 0,
424
866
  notes: notes || void 0,
425
867
  batchNumber: batchNumber || void 0,
426
- expirationDate: expirationDate || void 0
868
+ expirationDate: expirationDate || void 0,
869
+ supplierId: movementType === "entry" ? supplierId || void 0 : void 0
427
870
  });
428
871
  setSavedMovement(movement);
429
872
  } finally {
@@ -444,7 +887,7 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
444
887
  step === 1 && /* @__PURE__ */ jsxs("div", { className: "space-y-5", children: [
445
888
  /* @__PURE__ */ jsxs("div", { children: [
446
889
  /* @__PURE__ */ jsx("p", { className: "text-xs font-medium text-muted-foreground mb-2", children: t2("inventory.stock.movementType") }),
447
- /* @__PURE__ */ jsx("div", { className: "grid gap-2 sm:grid-cols-5", children: MOVEMENT_TYPES.map((mt) => {
890
+ /* @__PURE__ */ jsx("div", { className: "grid gap-2 sm:grid-cols-5", children: MOVEMENT_TYPES2.map((mt) => {
448
891
  const Icon = mt.icon;
449
892
  const active = movementType === mt.value;
450
893
  return /* @__PURE__ */ jsxs(
@@ -507,13 +950,13 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
507
950
  {
508
951
  onClick: () => setStep(2),
509
952
  disabled: !canProceedStep1,
510
- 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",
953
+ className: "inline-flex items-center gap-1.5 rounded-button 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",
511
954
  children: t2("inventory.stock.next")
512
955
  }
513
956
  ) })
514
957
  ] }),
515
958
  step === 2 && /* @__PURE__ */ jsxs("div", { className: "space-y-5", children: [
516
- /* @__PURE__ */ jsxs("div", { className: "rounded-lg border bg-card shadow-sm p-5 space-y-4", children: [
959
+ /* @__PURE__ */ jsxs("div", { className: "rounded-card border bg-card shadow-sm p-5 space-y-4", children: [
517
960
  /* @__PURE__ */ jsxs("div", { className: "grid gap-4 sm:grid-cols-3", children: [
518
961
  /* @__PURE__ */ jsxs("div", { children: [
519
962
  /* @__PURE__ */ jsx("label", { className: "text-xs font-medium text-muted-foreground", children: t2("inventory.stock.quantity") }),
@@ -553,7 +996,7 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
553
996
  type: "button",
554
997
  onClick: () => setLocationId(l.id),
555
998
  className: cn(
556
- "rounded-lg border-2 px-3 py-2 text-sm font-medium transition-all flex items-center gap-2",
999
+ "rounded-button border-2 px-3 py-2 text-sm font-medium transition-all flex items-center gap-2",
557
1000
  locationId === l.id ? "border-primary bg-primary/5 text-primary" : "border-transparent bg-muted/30 text-muted-foreground hover:bg-muted/50"
558
1001
  ),
559
1002
  children: [
@@ -578,7 +1021,7 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
578
1021
  type: "button",
579
1022
  onClick: () => setDestLocationId(l.id),
580
1023
  className: cn(
581
- "rounded-lg border-2 px-3 py-2 text-sm font-medium transition-all flex items-center gap-2",
1024
+ "rounded-button border-2 px-3 py-2 text-sm font-medium transition-all flex items-center gap-2",
582
1025
  destLocationId === l.id ? "border-primary bg-primary/5 text-primary" : "border-transparent bg-muted/30 text-muted-foreground hover:bg-muted/50"
583
1026
  ),
584
1027
  children: [
@@ -601,11 +1044,11 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
601
1044
  ] }),
602
1045
  /* @__PURE__ */ jsxs("div", { className: "flex justify-between", children: [
603
1046
  /* @__PURE__ */ jsx("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", children: t2("inventory.stock.back") }),
604
- /* @__PURE__ */ jsx("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", children: t2("inventory.stock.next") })
1047
+ /* @__PURE__ */ jsx("button", { onClick: () => setStep(3), disabled: !canProceedStep2, className: "inline-flex items-center gap-1.5 rounded-button 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", children: t2("inventory.stock.next") })
605
1048
  ] })
606
1049
  ] }),
607
1050
  step === 3 && /* @__PURE__ */ jsxs("div", { className: "space-y-5", children: [
608
- /* @__PURE__ */ jsxs("div", { className: "rounded-lg border bg-card shadow-sm p-5", children: [
1051
+ /* @__PURE__ */ jsxs("div", { className: "rounded-card border bg-card shadow-sm p-5", children: [
609
1052
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 mb-4", children: [
610
1053
  /* @__PURE__ */ jsx("div", { className: `flex h-10 w-10 items-center justify-center rounded-xl ${typeConfig.activeColor}`, children: /* @__PURE__ */ jsx(typeConfig.icon, { className: "h-5 w-5" }) }),
611
1054
  /* @__PURE__ */ jsxs("div", { children: [
@@ -646,9 +1089,26 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
646
1089
  ] })
647
1090
  ] })
648
1091
  ] }),
649
- /* @__PURE__ */ jsxs("div", { className: "rounded-lg border bg-card shadow-sm p-5 space-y-4", children: [
1092
+ /* @__PURE__ */ jsxs("div", { className: "rounded-card border bg-card shadow-sm p-5 space-y-4", children: [
650
1093
  /* @__PURE__ */ jsx("p", { className: "text-xs font-medium text-muted-foreground", children: t2("inventory.stock.additionalDetails") }),
651
1094
  /* @__PURE__ */ jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [
1095
+ movementType === "entry" && /* @__PURE__ */ jsx(
1096
+ SearchSelect,
1097
+ {
1098
+ value: supplierId,
1099
+ displayValue: supplierLabel,
1100
+ onChange: (id, option) => {
1101
+ setSupplierId(id);
1102
+ setSupplierLabel(option?.label ?? "");
1103
+ },
1104
+ onSearch: searchSuppliers,
1105
+ label: t2("inventory.stock.supplier"),
1106
+ placeholder: t2("inventory.stock.searchSupplier"),
1107
+ noResultsLabel: t2("inventory.stock.noSuppliers"),
1108
+ minChars: 1,
1109
+ debounce: 150
1110
+ }
1111
+ ),
652
1112
  /* @__PURE__ */ jsxs("div", { children: [
653
1113
  /* @__PURE__ */ jsx("label", { className: "text-xs font-medium text-muted-foreground", children: t2("inventory.stock.documentNumber") }),
654
1114
  /* @__PURE__ */ jsx("input", { type: "text", value: documentNumber, onChange: (e) => setDocumentNumber(e.target.value), placeholder: t2("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" })
@@ -676,7 +1136,7 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
676
1136
  {
677
1137
  onClick: handleSave,
678
1138
  disabled: saving,
679
- 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",
1139
+ className: "inline-flex items-center gap-1.5 rounded-button 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",
680
1140
  children: [
681
1141
  /* @__PURE__ */ jsx(Check, { className: "h-3.5 w-3.5" }),
682
1142
  " ",
@@ -695,6 +1155,10 @@ var TYPE_ICONS = {
695
1155
  transfer: ArrowRightLeft,
696
1156
  loss: Trash2
697
1157
  };
1158
+ function movementTypeLabel(type, t2) {
1159
+ const spec = movementTypeSpec(type);
1160
+ return spec ? t2(spec.labelKey) : type;
1161
+ }
698
1162
  var TYPE_COLORS = {
699
1163
  entry: "text-success",
700
1164
  exit: "text-destructive",
@@ -702,7 +1166,7 @@ var TYPE_COLORS = {
702
1166
  transfer: "text-magic",
703
1167
  loss: "text-warning"
704
1168
  };
705
- function MovementHistoryView({ onViewDetail } = {}) {
1169
+ function MovementHistoryView({ onViewDetail, onNew, onNewExit } = {}) {
706
1170
  const t2 = useTranslation();
707
1171
  const { currency } = useInventoryConfig();
708
1172
  const movements = useInventoryStore((s) => s.movements);
@@ -732,7 +1196,7 @@ function MovementHistoryView({ onViewDetail } = {}) {
732
1196
  const color = TYPE_COLORS[type] ?? "text-muted-foreground";
733
1197
  return /* @__PURE__ */ jsxs("span", { className: `inline-flex items-center gap-1 ${color}`, children: [
734
1198
  /* @__PURE__ */ jsx(Icon, { className: "h-3 w-3" }),
735
- /* @__PURE__ */ jsx("span", { className: "text-xs capitalize", children: type })
1199
+ /* @__PURE__ */ jsx("span", { className: "text-xs", children: movementTypeLabel(type, t2) })
736
1200
  ] });
737
1201
  }
738
1202
  },
@@ -774,108 +1238,764 @@ function MovementHistoryView({ onViewDetail } = {}) {
774
1238
  onSearchChange: setSearch,
775
1239
  searchDebounce: 0,
776
1240
  onRowClick: onViewDetail ? (row) => onViewDetail(row.id) : void 0,
777
- emptyMessage: t2("inventory.history.noMovements")
1241
+ newLabel: onNew ? t2("inventory.history.newEntry") : void 0,
1242
+ onNew,
1243
+ headerActions: onNewExit ? /* @__PURE__ */ jsxs(Button, { variant: "outline", size: "sm", onClick: onNewExit, children: [
1244
+ /* @__PURE__ */ jsx(ArrowUpRight, { className: "mr-1.5 h-3.5 w-3.5" }),
1245
+ t2("inventory.history.newExit")
1246
+ ] }) : void 0,
1247
+ emptyMessage: t2("inventory.history.noMovements"),
1248
+ emptyActionLabel: onNew ? t2("inventory.history.newEntry") : void 0,
1249
+ onEmptyAction: onNew
778
1250
  }
779
1251
  )
780
1252
  ] });
781
1253
  }
782
- function RecipeSkeleton() {
783
- return /* @__PURE__ */ jsx("div", { className: "grid gap-3 sm:grid-cols-2 lg:grid-cols-3", children: [1, 2, 3, 4, 5, 6].map((i) => /* @__PURE__ */ jsxs("div", { className: "rounded-lg border bg-card shadow-sm p-4 space-y-3", children: [
784
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
785
- /* @__PURE__ */ jsx("div", { className: "h-9 w-9 rounded-lg bg-muted/40 animate-pulse" }),
786
- /* @__PURE__ */ jsxs("div", { className: "flex-1 space-y-1.5", children: [
787
- /* @__PURE__ */ jsx("div", { className: "h-4 w-2/3 rounded bg-muted/40 animate-pulse" }),
788
- /* @__PURE__ */ jsx("div", { className: "h-3 w-1/2 rounded bg-muted/30 animate-pulse" })
1254
+ var STATUS_TONE = {
1255
+ open: "bg-muted text-muted-foreground",
1256
+ counting: "bg-info-soft text-info-soft-foreground",
1257
+ closed: "bg-success-soft text-success-soft-foreground",
1258
+ cancelled: "bg-muted text-muted-foreground line-through"
1259
+ };
1260
+ function buildCountEntity(t2) {
1261
+ return {
1262
+ name: t2("inventory.counts.entity"),
1263
+ namePlural: t2("inventory.counts.title"),
1264
+ icon: "ClipboardList",
1265
+ displayField: "label",
1266
+ fields: [
1267
+ {
1268
+ key: "label",
1269
+ label: t2("inventory.counts.colReference"),
1270
+ type: "text",
1271
+ searchable: true,
1272
+ renderCell: (_v, row) => /* @__PURE__ */ jsxs("div", { children: [
1273
+ /* @__PURE__ */ jsx("p", { className: "font-medium", children: row.label }),
1274
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: row.stockLocationName ?? "\u2014" })
1275
+ ] })
1276
+ },
1277
+ {
1278
+ key: "openedAt",
1279
+ label: t2("inventory.counts.colOpened"),
1280
+ type: "text",
1281
+ renderCell: (_v, row) => /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: row.openedAt?.slice(0, 10) ?? "\u2014" })
1282
+ },
1283
+ {
1284
+ key: "status",
1285
+ label: t2("inventory.counts.colStatus"),
1286
+ type: "text",
1287
+ renderCell: (_v, row) => /* @__PURE__ */ jsx("span", { className: `inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${STATUS_TONE[row.status]}`, children: t2(`inventory.counts.status.${row.status}`) })
1288
+ },
1289
+ {
1290
+ key: "progress",
1291
+ label: t2("inventory.counts.colProgress"),
1292
+ type: "text",
1293
+ renderCell: (_v, row) => /* @__PURE__ */ jsx("span", { className: "block text-right tabular-nums text-muted-foreground", children: t2("inventory.counts.progress", { counted: String(row.countedCount ?? 0), total: String(row.itemCount ?? 0) }) })
1294
+ },
1295
+ {
1296
+ key: "divergentCount",
1297
+ label: t2("inventory.counts.colDivergent"),
1298
+ type: "number",
1299
+ renderCell: (_v, row) => /* @__PURE__ */ jsx("span", { className: `block text-right tabular-nums ${row.divergentCount ? "font-medium text-warning-soft-foreground" : "text-muted-foreground"}`, children: row.divergentCount ?? 0 })
1300
+ }
1301
+ ]
1302
+ };
1303
+ }
1304
+ function StockCountsView({ onNew, onOpen }) {
1305
+ const t2 = useTranslation();
1306
+ const provider = useInventoryProvider();
1307
+ const [sessions, setSessions] = useState([]);
1308
+ const [loadedOnce, setLoadedOnce] = useState(false);
1309
+ const [search, setSearch] = useState("");
1310
+ const load = useCallback(async () => {
1311
+ setSessions(await provider.getCountSessions());
1312
+ setLoadedOnce(true);
1313
+ }, [provider]);
1314
+ useEffect(() => {
1315
+ void load();
1316
+ }, [load]);
1317
+ const rows = useMemo(() => {
1318
+ const term = search.trim().toLowerCase();
1319
+ return sessions.map((session) => ({
1320
+ ...session,
1321
+ label: session.reference || `${session.stockLocationName ?? ""} \xB7 ${session.openedAt.slice(0, 10)}`.trim()
1322
+ })).filter((row) => !term || row.label.toLowerCase().includes(term));
1323
+ }, [sessions, search]);
1324
+ const entity = useMemo(() => buildCountEntity(t2), [t2]);
1325
+ return /* @__PURE__ */ jsx(
1326
+ CrudListView,
1327
+ {
1328
+ entityDef: entity,
1329
+ items: loadedOnce ? rows : null,
1330
+ total: rows.length,
1331
+ search,
1332
+ onSearchChange: setSearch,
1333
+ searchPlaceholder: t2("inventory.counts.searchPlaceholder"),
1334
+ onNew,
1335
+ addLabel: t2("inventory.counts.newCount"),
1336
+ onRowClick: (row) => onOpen?.(row.id),
1337
+ emptyMessage: t2("inventory.counts.empty"),
1338
+ onRefresh: () => load(),
1339
+ feature: "inventory"
1340
+ }
1341
+ );
1342
+ }
1343
+ var FIELD = "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";
1344
+ function StockCountFormView({ onOpened, onCancel }) {
1345
+ const t2 = useTranslation();
1346
+ const provider = useInventoryProvider();
1347
+ const [locations, setLocations] = useState([]);
1348
+ const [categories, setCategories] = useState([]);
1349
+ const [locationId, setLocationId] = useState("");
1350
+ const [categoryId, setCategoryId] = useState("");
1351
+ const [reference, setReference] = useState("");
1352
+ const [blind, setBlind] = useState(true);
1353
+ const [notes, setNotes] = useState("");
1354
+ const [saving, setSaving] = useState(false);
1355
+ useEffect(() => {
1356
+ let active = true;
1357
+ void Promise.all([provider.getLocations(), provider.getCategories()]).then(([locs, cats]) => {
1358
+ if (!active) return;
1359
+ setLocations(locs);
1360
+ setCategories(cats);
1361
+ if (locs.length === 1) setLocationId(locs[0].id);
1362
+ });
1363
+ return () => {
1364
+ active = false;
1365
+ };
1366
+ }, [provider]);
1367
+ async function handleOpen() {
1368
+ if (!locationId) return;
1369
+ setSaving(true);
1370
+ try {
1371
+ const session = await provider.openCountSession({
1372
+ stockLocationId: locationId,
1373
+ categoryId: categoryId || void 0,
1374
+ reference: reference.trim() || void 0,
1375
+ blind,
1376
+ notes: notes.trim() || void 0
1377
+ });
1378
+ onOpened(session.id);
1379
+ } catch (err) {
1380
+ toast.error(t2("inventory.counts.openFailed"), { description: err?.message });
1381
+ } finally {
1382
+ setSaving(false);
1383
+ }
1384
+ }
1385
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
1386
+ /* @__PURE__ */ jsx(SubpageHeader, { title: t2("inventory.counts.title"), onBack: onCancel }),
1387
+ /* @__PURE__ */ jsxs("div", { className: "rounded-card border bg-card shadow-sm p-5 space-y-4 max-w-2xl", children: [
1388
+ /* @__PURE__ */ jsxs("div", { children: [
1389
+ /* @__PURE__ */ jsx("h2", { className: "text-sm font-semibold", children: t2("inventory.counts.formTitle") }),
1390
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: t2("inventory.counts.formSubtitle") })
1391
+ ] }),
1392
+ /* @__PURE__ */ jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [
1393
+ /* @__PURE__ */ jsxs("div", { children: [
1394
+ /* @__PURE__ */ jsx("label", { className: "text-xs font-medium text-muted-foreground", children: t2("inventory.counts.location") }),
1395
+ /* @__PURE__ */ jsxs("select", { className: FIELD, value: locationId, onChange: (e) => setLocationId(e.target.value), children: [
1396
+ /* @__PURE__ */ jsx("option", { value: "", children: t2("inventory.counts.pickLocation") }),
1397
+ locations.map((l) => /* @__PURE__ */ jsx("option", { value: l.id, children: l.name }, l.id))
1398
+ ] })
1399
+ ] }),
1400
+ /* @__PURE__ */ jsxs("div", { children: [
1401
+ /* @__PURE__ */ jsx("label", { className: "text-xs font-medium text-muted-foreground", children: t2("inventory.counts.category") }),
1402
+ /* @__PURE__ */ jsxs("select", { className: FIELD, value: categoryId, onChange: (e) => setCategoryId(e.target.value), children: [
1403
+ /* @__PURE__ */ jsx("option", { value: "", children: t2("inventory.counts.allCategories") }),
1404
+ categories.map((c) => /* @__PURE__ */ jsx("option", { value: c.id, children: c.name }, c.id))
1405
+ ] })
1406
+ ] })
1407
+ ] }),
1408
+ /* @__PURE__ */ jsxs("div", { children: [
1409
+ /* @__PURE__ */ jsx("label", { className: "text-xs font-medium text-muted-foreground", children: t2("inventory.counts.reference") }),
1410
+ /* @__PURE__ */ jsx(
1411
+ "input",
1412
+ {
1413
+ type: "text",
1414
+ className: FIELD,
1415
+ value: reference,
1416
+ onChange: (e) => setReference(e.target.value),
1417
+ placeholder: t2("inventory.counts.referencePlaceholder")
1418
+ }
1419
+ )
1420
+ ] }),
1421
+ /* @__PURE__ */ jsxs("div", { className: "rounded-lg bg-muted/30 px-4 py-3 flex items-start justify-between gap-4", children: [
1422
+ /* @__PURE__ */ jsxs("div", { children: [
1423
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-medium", children: t2("inventory.counts.blind") }),
1424
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: t2("inventory.counts.blindHint") })
1425
+ ] }),
1426
+ /* @__PURE__ */ jsx(Switch, { checked: blind, onCheckedChange: setBlind, size: "md" })
1427
+ ] }),
1428
+ /* @__PURE__ */ jsxs("div", { children: [
1429
+ /* @__PURE__ */ jsx("label", { className: "text-xs font-medium text-muted-foreground", children: t2("inventory.counts.notes") }),
1430
+ /* @__PURE__ */ jsx(
1431
+ "textarea",
1432
+ {
1433
+ className: FIELD,
1434
+ rows: 2,
1435
+ value: notes,
1436
+ onChange: (e) => setNotes(e.target.value),
1437
+ placeholder: t2("inventory.counts.notesPlaceholder")
1438
+ }
1439
+ )
1440
+ ] }),
1441
+ /* @__PURE__ */ jsxs("div", { className: "flex justify-end gap-2", children: [
1442
+ /* @__PURE__ */ jsx(Button, { variant: "outline", onClick: onCancel, children: t2("inventory.counts.cancel") }),
1443
+ /* @__PURE__ */ jsx(Button, { onClick: () => void handleOpen(), disabled: !locationId || saving, children: saving ? t2("inventory.counts.opening") : t2("inventory.counts.open") })
789
1444
  ] })
790
- ] }),
791
- /* @__PURE__ */ jsxs("div", { className: "flex gap-3", children: [
792
- /* @__PURE__ */ jsx("div", { className: "h-3 w-20 rounded bg-muted/30 animate-pulse" }),
793
- /* @__PURE__ */ jsx("div", { className: "h-3 w-16 rounded bg-muted/30 animate-pulse" })
794
1445
  ] })
795
- ] }, i)) });
1446
+ ] });
1447
+ }
1448
+
1449
+ // src/lib/stock-count.ts
1450
+ function isCounted(item) {
1451
+ return item.countedQuantity !== void 0 && item.countedQuantity !== null;
1452
+ }
1453
+ function lineVariance(item) {
1454
+ if (!isCounted(item)) return null;
1455
+ return (Number(item.countedQuantity) || 0) - (Number(item.systemQuantity) || 0);
1456
+ }
1457
+ function isDivergent(item) {
1458
+ const variance = lineVariance(item);
1459
+ return variance !== null && variance !== 0;
796
1460
  }
797
- function RecipesView({ onNew, onView }) {
1461
+ function summarizeCount(items) {
1462
+ let counted = 0;
1463
+ let divergent = 0;
1464
+ let variance = 0;
1465
+ let varianceValue = 0;
1466
+ for (const item of items) {
1467
+ const delta = lineVariance(item);
1468
+ if (delta === null) continue;
1469
+ counted += 1;
1470
+ if (delta === 0) continue;
1471
+ divergent += 1;
1472
+ variance += delta;
1473
+ varianceValue += delta * (Number(item.unitCost) || 0);
1474
+ }
1475
+ return {
1476
+ total: items.length,
1477
+ counted,
1478
+ uncounted: items.length - counted,
1479
+ divergent,
1480
+ variance,
1481
+ varianceValue
1482
+ };
1483
+ }
1484
+ function StockCountSessionView({ sessionId, onBack }) {
798
1485
  const t2 = useTranslation();
799
- const recipes = useInventoryStore((s) => s.recipes);
800
- const recipesLoading = useInventoryStore((s) => s.recipesLoading);
801
- const fetchRecipes = useInventoryStore((s) => s.fetchRecipes);
1486
+ const { currency } = useInventoryConfig();
1487
+ const provider = useInventoryProvider();
1488
+ const [session, setSession] = useState(null);
1489
+ const [items, setItems] = useState([]);
1490
+ const [drafts, setDrafts] = useState({});
1491
+ const [loading, setLoading] = useState(true);
1492
+ const [closing, setClosing] = useState(false);
1493
+ const [confirmingClose, setConfirmingClose] = useState(false);
1494
+ const [confirmingCancel, setConfirmingCancel] = useState(false);
1495
+ const inputs = useRef([]);
1496
+ const load = useCallback(async () => {
1497
+ setLoading(true);
1498
+ try {
1499
+ const [head, lines] = await Promise.all([
1500
+ provider.getCountSession(sessionId),
1501
+ provider.getCountItems(sessionId)
1502
+ ]);
1503
+ setSession(head);
1504
+ setItems(lines);
1505
+ setDrafts(Object.fromEntries(
1506
+ lines.map((l) => [l.id, l.countedQuantity === void 0 ? "" : String(l.countedQuantity)])
1507
+ ));
1508
+ } finally {
1509
+ setLoading(false);
1510
+ }
1511
+ }, [provider, sessionId]);
802
1512
  useEffect(() => {
803
- fetchRecipes();
804
- }, []);
805
- return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
1513
+ void load();
1514
+ }, [load]);
1515
+ const summary = useMemo(() => summarizeCount(items), [items]);
1516
+ const closed = session?.status === "closed" || session?.status === "cancelled";
1517
+ const hideVariance = (session?.blind ?? false) && !closed;
1518
+ async function commit(item, raw) {
1519
+ const trimmed = raw.trim();
1520
+ const next = trimmed === "" ? null : Number(trimmed);
1521
+ if (next !== null && !Number.isFinite(next)) return;
1522
+ const current = item.countedQuantity ?? null;
1523
+ if (next === current) return;
1524
+ try {
1525
+ const saved = await provider.saveCountEntry(item.id, next);
1526
+ setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, ...saved } : i));
1527
+ if (session?.status === "open") setSession({ ...session, status: "counting" });
1528
+ } catch (err) {
1529
+ toast.error(t2("inventory.counts.saveFailed"), { description: err?.message });
1530
+ }
1531
+ }
1532
+ async function handleClose() {
1533
+ setClosing(true);
1534
+ try {
1535
+ const result = await provider.closeCountSession(
1536
+ sessionId,
1537
+ t2("inventory.counts.adjustmentReason", { reference: session?.reference ?? "" })
1538
+ );
1539
+ toast.success(
1540
+ result.alreadyClosed ? t2("inventory.counts.alreadyClosed") : t2("inventory.counts.closed", { count: String(result.adjustmentsCreated) })
1541
+ );
1542
+ await load();
1543
+ } catch (err) {
1544
+ toast.error(t2("inventory.counts.closeFailed"), { description: err?.message });
1545
+ } finally {
1546
+ setClosing(false);
1547
+ setConfirmingClose(false);
1548
+ }
1549
+ }
1550
+ async function handleCancel() {
1551
+ try {
1552
+ await provider.cancelCountSession(sessionId);
1553
+ toast.success(t2("inventory.counts.cancelled"));
1554
+ await load();
1555
+ } catch (err) {
1556
+ toast.error(t2("inventory.counts.cancelFailed"), { description: err?.message });
1557
+ } finally {
1558
+ setConfirmingCancel(false);
1559
+ }
1560
+ }
1561
+ if (loading) {
1562
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
1563
+ /* @__PURE__ */ jsx(SubpageHeader, { title: t2("inventory.counts.title"), onBack }),
1564
+ /* @__PURE__ */ jsx("div", { className: "rounded-card border p-4 space-y-2", children: [1, 2, 3, 4].map((i) => /* @__PURE__ */ jsx("div", { className: "h-8 rounded bg-muted/30 animate-pulse" }, i)) })
1565
+ ] });
1566
+ }
1567
+ if (!session) {
1568
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
1569
+ /* @__PURE__ */ jsx(SubpageHeader, { title: t2("inventory.counts.title"), onBack }),
1570
+ /* @__PURE__ */ jsx("div", { className: "rounded-card border p-8 text-center", children: /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: t2("inventory.counts.notFound") }) })
1571
+ ] });
1572
+ }
1573
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-5", children: [
1574
+ /* @__PURE__ */ jsx(SubpageHeader, { title: t2("inventory.counts.title"), onBack }),
1575
+ /* @__PURE__ */ jsxs("div", { className: "rounded-card border bg-card shadow-sm p-4 flex flex-wrap items-center justify-between gap-4", children: [
1576
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
1577
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-semibold truncate", children: session.reference || session.stockLocationName || t2("inventory.counts.entity") }),
1578
+ /* @__PURE__ */ jsxs("p", { className: "text-xs text-muted-foreground", children: [
1579
+ session.stockLocationName ?? "\u2014",
1580
+ session.categoryName && /* @__PURE__ */ jsxs(Fragment, { children: [
1581
+ " \xB7 ",
1582
+ session.categoryName
1583
+ ] }),
1584
+ " ",
1585
+ "\xB7 ",
1586
+ t2(`inventory.counts.status.${session.status}`),
1587
+ session.blind && /* @__PURE__ */ jsxs(Fragment, { children: [
1588
+ " \xB7 ",
1589
+ t2("inventory.counts.blind")
1590
+ ] })
1591
+ ] })
1592
+ ] }),
1593
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-5 text-xs", children: [
1594
+ /* @__PURE__ */ jsxs("div", { children: [
1595
+ /* @__PURE__ */ jsx("p", { className: "text-muted-foreground", children: t2("inventory.counts.counted") }),
1596
+ /* @__PURE__ */ jsxs("p", { className: "font-semibold tabular-nums", children: [
1597
+ summary.counted,
1598
+ "/",
1599
+ summary.total
1600
+ ] })
1601
+ ] }),
1602
+ !hideVariance && /* @__PURE__ */ jsxs(Fragment, { children: [
1603
+ /* @__PURE__ */ jsxs("div", { children: [
1604
+ /* @__PURE__ */ jsx("p", { className: "text-muted-foreground", children: t2("inventory.counts.divergent") }),
1605
+ /* @__PURE__ */ jsx("p", { className: "font-semibold tabular-nums", children: summary.divergent })
1606
+ ] }),
1607
+ /* @__PURE__ */ jsxs("div", { children: [
1608
+ /* @__PURE__ */ jsx("p", { className: "text-muted-foreground", children: t2("inventory.counts.varianceValue") }),
1609
+ /* @__PURE__ */ jsx("p", { className: `font-semibold tabular-nums ${summary.varianceValue < 0 ? "text-destructive" : ""}`, children: formatCurrency(summary.varianceValue, currency) })
1610
+ ] })
1611
+ ] })
1612
+ ] }),
1613
+ !closed && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
1614
+ /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: () => setConfirmingCancel(true), children: t2("inventory.counts.cancelCount") }),
1615
+ /* @__PURE__ */ jsx(Button, { size: "sm", onClick: () => setConfirmingClose(true), children: t2("inventory.counts.close") })
1616
+ ] })
1617
+ ] }),
1618
+ /* @__PURE__ */ jsxs("div", { className: "rounded-card border bg-card shadow-sm divide-y", children: [
1619
+ items.map((item, index) => {
1620
+ const variance = lineVariance(item);
1621
+ return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 px-4 py-3", children: [
1622
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
1623
+ /* @__PURE__ */ jsx("p", { className: "text-sm font-medium truncate", children: item.productName ?? item.productId }),
1624
+ /* @__PURE__ */ jsxs("p", { className: "text-xs text-muted-foreground", children: [
1625
+ item.productSku ? `${item.productSku} \xB7 ` : "",
1626
+ hideVariance ? t2("inventory.counts.blindLine") : `${t2("inventory.counts.systemQty")} ${item.systemQuantity}`
1627
+ ] })
1628
+ ] }),
1629
+ !hideVariance && /* @__PURE__ */ jsx("span", { className: `w-16 shrink-0 text-right text-sm tabular-nums ${variance === null ? "text-muted-foreground" : variance === 0 ? "text-muted-foreground" : variance > 0 ? "text-success" : "text-destructive"}`, children: variance === null ? "\u2014" : variance > 0 ? `+${variance}` : variance }),
1630
+ /* @__PURE__ */ jsx(
1631
+ "input",
1632
+ {
1633
+ ref: (el) => {
1634
+ inputs.current[index] = el;
1635
+ },
1636
+ type: "number",
1637
+ inputMode: "decimal",
1638
+ step: "0.01",
1639
+ disabled: closed,
1640
+ value: drafts[item.id] ?? "",
1641
+ placeholder: t2("inventory.counts.notCounted"),
1642
+ onChange: (e) => setDrafts((d) => ({ ...d, [item.id]: e.target.value })),
1643
+ onBlur: () => void commit(item, drafts[item.id] ?? ""),
1644
+ onKeyDown: (e) => {
1645
+ if (e.key !== "Enter") return;
1646
+ e.preventDefault();
1647
+ inputs.current[index + 1]?.focus();
1648
+ },
1649
+ className: "w-28 shrink-0 rounded-input border border-input bg-card px-3 py-2.5 text-right text-base font-medium tabular-nums shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
1650
+ }
1651
+ )
1652
+ ] }, item.id);
1653
+ }),
1654
+ items.length === 0 && /* @__PURE__ */ jsx("p", { className: "px-4 py-8 text-center text-xs text-muted-foreground", children: t2("inventory.counts.noItems") })
1655
+ ] }),
806
1656
  /* @__PURE__ */ jsx(
807
- SubpageHeader,
1657
+ ConfirmDialog,
808
1658
  {
809
- title: t2("inventory.recipes.title"),
810
- subtitle: t2("inventory.recipes.productionFormulas", { count: String(recipes.length) }),
811
- actions: onNew && /* @__PURE__ */ jsx(PermissionGate, { feature: "inventory", action: "create", children: /* @__PURE__ */ jsxs("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", children: [
812
- /* @__PURE__ */ jsx(Plus, { className: "h-3.5 w-3.5" }),
813
- " ",
814
- t2("inventory.recipes.newRecipe")
815
- ] }) })
1659
+ open: confirmingClose,
1660
+ title: t2("inventory.counts.closeTitle"),
1661
+ description: t2("inventory.counts.closeDescription", {
1662
+ divergent: String(summary.divergent),
1663
+ uncounted: String(summary.uncounted)
1664
+ }),
1665
+ confirmLabel: t2("inventory.counts.closeConfirm"),
1666
+ cancelLabel: t2("inventory.counts.cancel"),
1667
+ loading: closing,
1668
+ onConfirm: () => void handleClose(),
1669
+ onCancel: () => setConfirmingClose(false)
816
1670
  }
817
1671
  ),
818
- recipesLoading ? /* @__PURE__ */ jsx(RecipeSkeleton, {}) : recipes.length === 0 ? /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center justify-center py-16 text-center rounded-lg border-2 border-dashed border-muted", children: [
819
- /* @__PURE__ */ jsx("div", { className: "flex h-12 w-12 items-center justify-center rounded-2xl bg-muted/30 mb-3", children: /* @__PURE__ */ jsx(BookOpen, { className: "h-5 w-5 text-muted-foreground/40" }) }),
820
- /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: t2("inventory.recipes.noRecipes") }),
821
- /* @__PURE__ */ jsx("p", { className: "text-[10px] text-muted-foreground/60 mt-0.5", children: t2("inventory.recipes.recipesDesc") }),
822
- onNew && /* @__PURE__ */ jsx("button", { onClick: onNew, className: "text-xs text-primary hover:underline mt-2", children: t2("inventory.recipes.createFirst") })
823
- ] }) : /* @__PURE__ */ jsx("div", { className: "grid gap-3 sm:grid-cols-2 lg:grid-cols-3", children: recipes.map((r) => /* @__PURE__ */ jsxs(
824
- "div",
1672
+ /* @__PURE__ */ jsx(
1673
+ ConfirmDialog,
825
1674
  {
826
- onClick: () => onView?.(r.id),
827
- className: "rounded-lg border bg-card p-4 hover:shadow-sm hover:border-primary/20 transition-all cursor-pointer group",
828
- children: [
829
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
830
- /* @__PURE__ */ jsx("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", children: /* @__PURE__ */ jsx(BookOpen, { className: "h-4 w-4" }) }),
831
- /* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
832
- /* @__PURE__ */ jsx("h3", { className: "text-sm font-semibold truncate", children: r.name }),
833
- r.productName && /* @__PURE__ */ jsxs("p", { className: "text-[10px] text-muted-foreground truncate", children: [
834
- t2("inventory.recipes.produces"),
835
- " ",
836
- r.productName
837
- ] })
838
- ] })
839
- ] }),
840
- r.description && /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground mt-2 line-clamp-2", children: r.description }),
841
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-4 mt-3 pt-2.5 border-t", children: [
842
- r.ingredientCount != null && /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1 text-[10px] text-muted-foreground", children: [
843
- /* @__PURE__ */ jsx(Layers, { className: "h-3 w-3" }),
844
- " ",
845
- t2("inventory.recipes.ingredients", { count: String(r.ingredientCount) })
846
- ] }),
847
- r.preparationTimeMinutes != null && /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1 text-[10px] text-muted-foreground", children: [
848
- /* @__PURE__ */ jsx(Clock, { className: "h-3 w-3" }),
849
- " ",
850
- r.preparationTimeMinutes,
851
- " min"
852
- ] }),
853
- r.yieldQuantity > 0 && /* @__PURE__ */ jsxs("span", { className: "text-[10px] text-muted-foreground ml-auto", children: [
854
- t2("inventory.recipes.yield"),
855
- " ",
856
- r.yieldQuantity,
857
- r.yieldUnitName ? ` ${r.yieldUnitName}` : ""
858
- ] })
859
- ] })
860
- ]
861
- },
862
- r.id
863
- )) })
1675
+ open: confirmingCancel,
1676
+ variant: "destructive",
1677
+ title: t2("inventory.counts.cancelTitle"),
1678
+ description: t2("inventory.counts.cancelDescription"),
1679
+ confirmLabel: t2("inventory.counts.cancelConfirm"),
1680
+ cancelLabel: t2("inventory.counts.cancel"),
1681
+ onConfirm: () => void handleCancel(),
1682
+ onCancel: () => setConfirmingCancel(false)
1683
+ }
1684
+ )
864
1685
  ] });
865
1686
  }
1687
+
1688
+ // src/lib/recipe-cost.ts
1689
+ var CMV_ALERT_THRESHOLD = 35;
1690
+ function ingredientUnitCost(basis) {
1691
+ if (!basis) return 0;
1692
+ const cost = Number(basis.cost) || 0;
1693
+ if (!basis.purchaseUnitId) return cost;
1694
+ const factor = Number(basis.conversionFactor) || 0;
1695
+ return factor > 0 ? cost / factor : cost;
1696
+ }
1697
+ function lineCost(line) {
1698
+ return ingredientUnitCost(line.costBasis) * (Number(line.quantity) || 0);
1699
+ }
1700
+ function recipeCost(lines) {
1701
+ return lines.reduce((sum, line) => sum + lineCost(line), 0);
1702
+ }
1703
+ function cmvPercent(cost, salePrice) {
1704
+ const price = Number(salePrice) || 0;
1705
+ if (price <= 0) return null;
1706
+ return (Number(cost) || 0) / price * 100;
1707
+ }
1708
+ function isCmvHigh(cmv) {
1709
+ return typeof cmv === "number" && cmv > CMV_ALERT_THRESHOLD;
1710
+ }
1711
+
1712
+ // src/lib/agent-tools.ts
1713
+ var INGREDIENT_SHAPE = "An ingredient line: { product_id?, name, quantity, notes? }. Prefer product_id from the catalogue in the screen context; send `name` too so an unmatched line can still be shown to the user. quantity is a number in the ingredient's own unit \u2014 the catalogue lists that unit.";
1714
+ var upsertRecipeTool = {
1715
+ id: "inventory.upsertRecipe",
1716
+ name: "upsertRecipe",
1717
+ description: "Creates or edits a ficha t\xE9cnica (recipe) for a menu item: what it is made of, how much it yields and how it is prepared. Persisted server-side, so it works from ANY page. Omit recipeId to CREATE (name required); pass recipeId to EDIT. Propose the WHOLE recipe in a single call \u2014 ingredients, quantities, yield and preparation together. Do NOT interview the user ingredient by ingredient. Use the ingredient catalogue in the screen context to pick real product ids; name anything else in `name` and it will come back flagged for the user to pick or create. The user confirms before it is saved.",
1718
+ icon: "BookOpen",
1719
+ mode: "persist",
1720
+ role: "finance",
1721
+ limitKey: "recipes",
1722
+ permission: { feature: "inventory.recipes", action: "create" },
1723
+ execution: { plane: "server", kind: "rpc", rpc: "agent_inventory_upsert_recipe" },
1724
+ category: "Inventory",
1725
+ suggestions: [
1726
+ { label: "Monte a ficha t\xE9cnica do cupim defumado com queijo, 400g" },
1727
+ { label: "Quais itens do card\xE1pio ainda n\xE3o t\xEAm ficha t\xE9cnica?" }
1728
+ ],
1729
+ parameters: {
1730
+ type: "object",
1731
+ properties: {
1732
+ recipeId: { type: "string", description: "Existing recipe id to EDIT. Omit to create a new one." },
1733
+ name: { type: "string", description: "Recipe name \u2014 required when creating." },
1734
+ product_id: { type: "string", description: "Id of the product this recipe produces, from the screen context." },
1735
+ product_name: { type: "string", description: "Name of the produced product, when its id is unknown. Resolved against the catalogue." },
1736
+ description: { type: "string", description: "Optional short description." },
1737
+ yield_quantity: { type: "number", description: "How many units one batch yields. Defaults to 1." },
1738
+ preparation_time_minutes: { type: "number", description: "Preparation time in minutes." },
1739
+ instructions: { type: "string", description: "Step-by-step preparation." },
1740
+ ingredients: {
1741
+ type: "array",
1742
+ description: `The FULL ingredient list \u2014 REPLACES every existing line. Each item: ${INGREDIENT_SHAPE}`,
1743
+ items: { type: "object", description: INGREDIENT_SHAPE }
1744
+ }
1745
+ }
1746
+ }
1747
+ };
1748
+ var RECIPE_SURFACE_INSTRUCTIONS = [
1749
+ "Voc\xEA est\xE1 na tela de FICHAS T\xC9CNICAS. Para montar ou editar uma ficha use a tool upsertRecipe (ela persiste no backend).",
1750
+ "O cat\xE1logo de insumos (id, nome, unidade, custo) e os itens do card\xE1pio sem ficha est\xE3o no JSON da tela em contexto \u2014 use os ids de l\xE1 em vez de inventar.",
1751
+ "Proponha a ficha inteira numa \xFAnica chamada: ingredientes, quantidades, rendimento e preparo juntos. N\xE3o pergunte ingrediente a ingrediente.",
1752
+ "Insumo que n\xE3o existir no cat\xE1logo: mande mesmo assim em `name` \u2014 ele volta marcado para o usu\xE1rio escolher ou cadastrar."
1753
+ ].join(" ");
1754
+ function isSellable(product) {
1755
+ return product.productType === "sale" || (product.salePrice ?? 0) > 0;
1756
+ }
1757
+ function buildRecipeEntity(t2, currency) {
1758
+ const money = (value) => value == null ? "\u2014" : formatCurrency(value, currency);
1759
+ return {
1760
+ name: t2("inventory.recipes.entity"),
1761
+ namePlural: t2("inventory.recipes.title"),
1762
+ icon: "BookOpen",
1763
+ displayField: "productName",
1764
+ fields: [
1765
+ {
1766
+ key: "productName",
1767
+ label: t2("inventory.recipes.colProduct"),
1768
+ type: "text",
1769
+ searchable: true,
1770
+ renderCell: (_v, row) => /* @__PURE__ */ jsxs("div", { children: [
1771
+ /* @__PURE__ */ jsx("p", { className: "font-medium", children: row.productName }),
1772
+ row.missing ? /* @__PURE__ */ jsx("span", { className: "mt-0.5 inline-flex items-center rounded-full bg-warning-soft px-2 py-0.5 text-[10px] font-medium text-warning-soft-foreground", children: t2("inventory.recipes.noSheet") }) : row.recipeName && row.recipeName !== row.productName ? /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: row.recipeName }) : null
1773
+ ] })
1774
+ },
1775
+ {
1776
+ key: "ingredientCount",
1777
+ label: t2("inventory.recipes.colIngredients"),
1778
+ type: "number",
1779
+ renderCell: (_v, row) => /* @__PURE__ */ jsx("span", { className: "block text-right tabular-nums", children: row.missing ? "\u2014" : row.ingredientCount })
1780
+ },
1781
+ {
1782
+ key: "yieldLabel",
1783
+ label: t2("inventory.recipes.colYield"),
1784
+ type: "text",
1785
+ renderCell: (_v, row) => /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: row.yieldLabel || "\u2014" })
1786
+ },
1787
+ {
1788
+ key: "prepLabel",
1789
+ label: t2("inventory.recipes.colPrep"),
1790
+ type: "text",
1791
+ renderCell: (_v, row) => /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: row.prepLabel || "\u2014" })
1792
+ },
1793
+ {
1794
+ key: "cost",
1795
+ label: t2("inventory.recipes.colCost"),
1796
+ type: "currency",
1797
+ renderCell: (_v, row) => /* @__PURE__ */ jsx("span", { className: "block text-right tabular-nums text-muted-foreground", children: money(row.cost) })
1798
+ },
1799
+ {
1800
+ key: "salePrice",
1801
+ label: t2("inventory.recipes.colSale"),
1802
+ type: "currency",
1803
+ renderCell: (_v, row) => /* @__PURE__ */ jsx("span", { className: "block text-right tabular-nums", children: money(row.salePrice ?? null) })
1804
+ },
1805
+ {
1806
+ key: "cmv",
1807
+ label: t2("inventory.recipes.colCmv"),
1808
+ type: "number",
1809
+ renderCell: (_v, row) => row.cmv == null ? /* @__PURE__ */ jsx("span", { className: "block text-right text-muted-foreground", children: "\u2014" }) : /* @__PURE__ */ jsxs("span", { className: `block text-right tabular-nums font-medium ${isCmvHigh(row.cmv) ? "text-destructive" : "text-success"}`, children: [
1810
+ row.cmv.toFixed(1),
1811
+ "%"
1812
+ ] })
1813
+ }
1814
+ ]
1815
+ };
1816
+ }
1817
+ function RecipesView({ onNew, onView, onNewForProduct }) {
1818
+ const t2 = useTranslation();
1819
+ const { currency } = useInventoryConfig();
1820
+ const provider = useInventoryProvider();
1821
+ const [recipes, setRecipes] = useState([]);
1822
+ const [lines, setLines] = useState([]);
1823
+ const [products, setProducts] = useState([]);
1824
+ const [loadedOnce, setLoadedOnce] = useState(false);
1825
+ const [search, setSearch] = useState("");
1826
+ const load = useCallback(async () => {
1827
+ const [recipeList, allLines, productPage] = await Promise.all([
1828
+ provider.getRecipes(),
1829
+ provider.getAllRecipeIngredients(),
1830
+ provider.getProducts({ pageSize: 500 })
1831
+ ]);
1832
+ setRecipes(recipeList);
1833
+ setLines(allLines);
1834
+ setProducts(productPage.data);
1835
+ setLoadedOnce(true);
1836
+ return recipeList;
1837
+ }, [provider]);
1838
+ useEffect(() => {
1839
+ void load();
1840
+ }, [load]);
1841
+ const knownRecipeIds = useRef(/* @__PURE__ */ new Set());
1842
+ useEffect(() => {
1843
+ knownRecipeIds.current = new Set(recipes.map((r) => r.id));
1844
+ }, [recipes]);
1845
+ useDataChanged({}, () => {
1846
+ void (async () => {
1847
+ const before = knownRecipeIds.current;
1848
+ const after = await load();
1849
+ const created = after.find((r) => !before.has(r.id));
1850
+ if (created && onView) onView(created.id);
1851
+ })();
1852
+ });
1853
+ const rows = useMemo(() => {
1854
+ const linesByRecipe = /* @__PURE__ */ new Map();
1855
+ for (const line of lines) {
1856
+ const bucket = linesByRecipe.get(line.recipeId);
1857
+ if (bucket) bucket.push(line);
1858
+ else linesByRecipe.set(line.recipeId, [line]);
1859
+ }
1860
+ const productById = new Map(products.map((p) => [p.id, p]));
1861
+ const built = recipes.map((recipe) => {
1862
+ const own = linesByRecipe.get(recipe.id) ?? [];
1863
+ const cost = recipeCost(own);
1864
+ const product = recipe.productId ? productById.get(recipe.productId) : void 0;
1865
+ const salePrice = recipe.productSalePrice ?? product?.salePrice;
1866
+ return {
1867
+ id: recipe.id,
1868
+ recipeId: recipe.id,
1869
+ productId: recipe.productId,
1870
+ productName: recipe.productName ?? product?.name ?? recipe.name,
1871
+ recipeName: recipe.name,
1872
+ ingredientCount: own.length || recipe.ingredientCount || 0,
1873
+ yieldLabel: recipe.yieldQuantity ? `${recipe.yieldQuantity}${recipe.yieldUnitName ? ` ${recipe.yieldUnitName}` : ""}` : "",
1874
+ prepLabel: recipe.preparationTimeMinutes != null ? `${recipe.preparationTimeMinutes} min` : "",
1875
+ cost,
1876
+ salePrice,
1877
+ cmv: cmvPercent(cost, salePrice),
1878
+ missing: false
1879
+ };
1880
+ });
1881
+ const covered = new Set(recipes.map((r) => r.productId).filter(Boolean));
1882
+ for (const product of products) {
1883
+ if (!isSellable(product) || covered.has(product.id)) continue;
1884
+ built.push({
1885
+ id: `product:${product.id}`,
1886
+ productId: product.id,
1887
+ productName: product.name,
1888
+ ingredientCount: 0,
1889
+ yieldLabel: "",
1890
+ prepLabel: "",
1891
+ cost: null,
1892
+ salePrice: product.salePrice,
1893
+ cmv: null,
1894
+ missing: true
1895
+ });
1896
+ }
1897
+ return built.sort((a, b) => a.missing === b.missing ? a.productName.localeCompare(b.productName) : a.missing ? -1 : 1);
1898
+ }, [recipes, lines, products]);
1899
+ const visibleRows = useMemo(() => {
1900
+ const term = search.trim().toLowerCase();
1901
+ return term ? rows.filter((r) => r.productName.toLowerCase().includes(term)) : rows;
1902
+ }, [rows, search]);
1903
+ const entity = useMemo(() => buildRecipeEntity(t2, currency), [t2, currency]);
1904
+ const surfaceState = useRef({ rows, products });
1905
+ surfaceState.current = { rows, products };
1906
+ const surface = useMemo(() => ({
1907
+ id: "inventory.recipes",
1908
+ pluginId: "inventory",
1909
+ role: "finance",
1910
+ instructions: RECIPE_SURFACE_INSTRUCTIONS,
1911
+ describe: () => {
1912
+ const { rows: current, products: catalogue } = surfaceState.current;
1913
+ return {
1914
+ title: "Fichas t\xE9cnicas",
1915
+ state: {
1916
+ ingredients: catalogue.filter((p) => !isSellable(p) || (p.costPrice ?? 0) > 0).slice(0, 200).map((p) => ({ id: p.id, name: p.name, unit: p.measurementUnitName, cost: p.costPrice })),
1917
+ menuItemsWithoutRecipe: current.filter((r) => r.missing).map((r) => ({ productId: r.productId, name: r.productName, salePrice: r.salePrice })),
1918
+ recipes: current.filter((r) => !r.missing).map((r) => ({ id: r.recipeId, product: r.productName, cost: r.cost, cmv: r.cmv })),
1919
+ cmvAlertThreshold: CMV_ALERT_THRESHOLD
1920
+ }
1921
+ };
1922
+ },
1923
+ tools: []
1924
+ }), []);
1925
+ useAgentSurface(surface);
1926
+ const createActions = useMemo(() => [
1927
+ {
1928
+ id: "with-ai",
1929
+ label: t2("assistant.askAI"),
1930
+ description: t2("inventory.recipes.aiHint"),
1931
+ icon: "WandSparkles",
1932
+ highlight: true,
1933
+ action: () => askAI({
1934
+ hint: t2("inventory.recipes.askPlaceholder"),
1935
+ intro: {
1936
+ role: "finance",
1937
+ persona: t2("inventory.recipes.askPersona"),
1938
+ pitch: t2("inventory.recipes.askPitch"),
1939
+ description: t2("inventory.recipes.askDescription"),
1940
+ suggestions: [0, 1, 2].map((i) => ({ label: t2(`inventory.recipes.askSuggestion.${i}`) }))
1941
+ }
1942
+ })
1943
+ },
1944
+ ...onNew ? [{
1945
+ id: "by-hand",
1946
+ label: t2("inventory.recipes.newByHand"),
1947
+ description: t2("inventory.recipes.newByHandHint"),
1948
+ icon: "SlidersHorizontal",
1949
+ action: onNew
1950
+ }] : []
1951
+ ], [t2, onNew]);
1952
+ return /* @__PURE__ */ jsx(
1953
+ CrudListView,
1954
+ {
1955
+ entityDef: entity,
1956
+ items: loadedOnce ? visibleRows : null,
1957
+ total: visibleRows.length,
1958
+ search,
1959
+ onSearchChange: setSearch,
1960
+ searchPlaceholder: t2("inventory.recipes.searchPlaceholder"),
1961
+ headerActions: /* @__PURE__ */ jsx(PermissionGate, { feature: "inventory", action: "create", children: /* @__PURE__ */ jsx(QuickActionsButton, { variant: "default", label: t2("inventory.recipes.newRecipe"), actions: createActions }) }),
1962
+ onRowClick: (row) => {
1963
+ const target = row;
1964
+ if (target.missing && target.productId) onNewForProduct?.(target.productId);
1965
+ else if (target.recipeId) onView?.(target.recipeId);
1966
+ },
1967
+ emptyMessage: t2("inventory.recipes.noRecipes"),
1968
+ onRefresh: () => load().then(() => void 0),
1969
+ feature: "inventory"
1970
+ }
1971
+ );
1972
+ }
866
1973
  var fid = 1;
867
1974
  function nextId() {
868
1975
  return `ri${fid++}`;
869
1976
  }
870
- function RecipeFormView({ onSaved }) {
1977
+ function RecipeFormView({ onSaved, productId: initialProductId }) {
871
1978
  const t2 = useTranslation();
872
1979
  const provider = useInventoryProvider();
873
1980
  const createRecipe = useInventoryStore((s) => s.createRecipe);
874
1981
  const guardRecipes = useLimitGuard("recipes");
875
1982
  const [name, setName] = useState("");
876
1983
  const [description, setDescription] = useState("");
877
- const [productId, setProductId] = useState("");
1984
+ const [productId, setProductId] = useState(initialProductId ?? "");
878
1985
  const [productName, setProductName] = useState("");
1986
+ useEffect(() => {
1987
+ if (!initialProductId) return;
1988
+ let active = true;
1989
+ void provider.getProductById(initialProductId).then((product) => {
1990
+ if (!active || !product) return;
1991
+ setProductId(product.id);
1992
+ setProductName(product.name);
1993
+ setName((current) => current || product.name);
1994
+ });
1995
+ return () => {
1996
+ active = false;
1997
+ };
1998
+ }, [initialProductId, provider]);
879
1999
  const [yieldQuantity, setYieldQuantity] = useState(1);
880
2000
  const [prepTime, setPrepTime] = useState("");
881
2001
  const [instructions, setInstructions] = useState("");
@@ -954,7 +2074,7 @@ function RecipeFormView({ onSaved }) {
954
2074
  }
955
2075
  ),
956
2076
  /* @__PURE__ */ jsxs("div", { className: "grid gap-4 lg:grid-cols-3", children: [
957
- /* @__PURE__ */ jsxs("div", { className: "rounded-lg border bg-card shadow-sm p-5 space-y-4", children: [
2077
+ /* @__PURE__ */ jsxs("div", { className: "rounded-card border bg-card shadow-sm p-5 space-y-4", children: [
958
2078
  /* @__PURE__ */ jsx("h3", { className: "text-sm font-semibold", children: t2("inventory.recipeForm.recipeDetails") }),
959
2079
  /* @__PURE__ */ jsxs("div", { children: [
960
2080
  /* @__PURE__ */ jsxs("label", { className: "text-xs font-medium text-muted-foreground", children: [
@@ -1005,10 +2125,10 @@ function RecipeFormView({ onSaved }) {
1005
2125
  /* @__PURE__ */ jsx("textarea", { value: instructions, onChange: (e) => setInstructions(e.target.value), rows: 4, placeholder: t2("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" })
1006
2126
  ] })
1007
2127
  ] }),
1008
- /* @__PURE__ */ jsxs("div", { className: "lg:col-span-2 rounded-lg border bg-card shadow-sm overflow-hidden flex flex-col", children: [
2128
+ /* @__PURE__ */ jsxs("div", { className: "lg:col-span-2 rounded-card border bg-card shadow-sm overflow-hidden flex flex-col", children: [
1009
2129
  /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between px-4 py-3 border-b", children: [
1010
2130
  /* @__PURE__ */ jsx("h3", { className: "text-sm font-semibold", children: t2("inventory.recipeForm.ingredients") }),
1011
- /* @__PURE__ */ jsxs("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", children: [
2131
+ /* @__PURE__ */ jsxs("button", { onClick: addIngredient, className: "inline-flex items-center gap-1.5 rounded-button 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", children: [
1012
2132
  /* @__PURE__ */ jsx(Plus, { className: "h-3 w-3" }),
1013
2133
  " ",
1014
2134
  t2("inventory.recipeForm.addIngredient")
@@ -1070,7 +2190,130 @@ function RecipeFormView({ onSaved }) {
1070
2190
  ] })
1071
2191
  ] });
1072
2192
  }
1073
- function DetailSkeleton() {
2193
+ function RecipeGapsPanel({ recipe, gaps, onResolved }) {
2194
+ const t2 = useTranslation();
2195
+ const provider = useInventoryProvider();
2196
+ const [busy, setBusy] = useState(false);
2197
+ const unmatched = gaps.unmatchedIngredients ?? [];
2198
+ if (unmatched.length === 0 && !gaps.productHint) return null;
2199
+ async function searchProducts(query) {
2200
+ const result = await provider.getProducts({ search: query, pageSize: 10 });
2201
+ return result.data.map((p) => ({ id: p.id, label: p.name, subtitle: p.sku ?? p.productType, data: p }));
2202
+ }
2203
+ async function quickCreate(name, productType) {
2204
+ try {
2205
+ return await provider.createProduct({ name, productType });
2206
+ } catch (err) {
2207
+ toast.error(t2("inventory.recipeGaps.createFailed"), { description: err?.message });
2208
+ return null;
2209
+ }
2210
+ }
2211
+ function remainingGaps(index) {
2212
+ return { ...gaps, unmatchedIngredients: unmatched.filter((_, i) => i !== index) };
2213
+ }
2214
+ async function linkIngredient(index, productId) {
2215
+ const gap = unmatched[index];
2216
+ setBusy(true);
2217
+ try {
2218
+ await provider.addRecipeIngredient(recipe.id, {
2219
+ productId,
2220
+ quantity: Number(gap?.quantity) > 0 ? Number(gap.quantity) : 1,
2221
+ notes: gap?.notes
2222
+ });
2223
+ await provider.updateRecipe(recipe.id, { metadata: remainingGaps(index) });
2224
+ await onResolved();
2225
+ } catch (err) {
2226
+ toast.error(t2("inventory.recipeGaps.linkFailed"), { description: err?.message });
2227
+ } finally {
2228
+ setBusy(false);
2229
+ }
2230
+ }
2231
+ async function linkProduct(productId) {
2232
+ setBusy(true);
2233
+ try {
2234
+ const { productHint: _dropped, ...rest } = gaps;
2235
+ await provider.updateRecipe(recipe.id, { productId, metadata: rest });
2236
+ await onResolved();
2237
+ } catch (err) {
2238
+ toast.error(t2("inventory.recipeGaps.linkFailed"), { description: err?.message });
2239
+ } finally {
2240
+ setBusy(false);
2241
+ }
2242
+ }
2243
+ return /* @__PURE__ */ jsxs("div", { className: "rounded-card border border-warning/40 bg-warning/5 p-4 space-y-3", children: [
2244
+ /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-2", children: [
2245
+ /* @__PURE__ */ jsx(AlertTriangle, { className: "h-4 w-4 text-warning shrink-0 mt-px" }),
2246
+ /* @__PURE__ */ jsxs("div", { children: [
2247
+ /* @__PURE__ */ jsx("h3", { className: "text-sm font-semibold text-foreground", children: t2("inventory.recipeGaps.title") }),
2248
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: t2("inventory.recipeGaps.description") })
2249
+ ] })
2250
+ ] }),
2251
+ /* @__PURE__ */ jsxs("div", { className: `space-y-2 ${busy ? "opacity-60 pointer-events-none" : ""}`, children: [
2252
+ gaps.productHint && /* @__PURE__ */ jsxs("div", { className: "grid gap-2 sm:grid-cols-2 items-center", children: [
2253
+ /* @__PURE__ */ jsxs("p", { className: "text-xs", children: [
2254
+ /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: t2("inventory.recipeGaps.produces") }),
2255
+ " ",
2256
+ /* @__PURE__ */ jsx("strong", { className: "text-foreground", children: gaps.productHint })
2257
+ ] }),
2258
+ /* @__PURE__ */ jsx(
2259
+ SearchSelect,
2260
+ {
2261
+ value: "",
2262
+ onChange: (id) => {
2263
+ if (id) void linkProduct(id);
2264
+ },
2265
+ onSearch: searchProducts,
2266
+ placeholder: t2("inventory.recipeForm.searchProduct"),
2267
+ allowCreate: true,
2268
+ createLabel: t2("inventory.recipeForm.createProduct"),
2269
+ onCreate: async (q) => {
2270
+ const p = await quickCreate(q, "sale");
2271
+ if (p) await linkProduct(p.id);
2272
+ }
2273
+ }
2274
+ )
2275
+ ] }),
2276
+ unmatched.map((gap, index) => /* @__PURE__ */ jsxs("div", { className: "grid gap-2 sm:grid-cols-2 items-center", children: [
2277
+ /* @__PURE__ */ jsxs("p", { className: "text-xs", children: [
2278
+ /* @__PURE__ */ jsx("strong", { className: "text-foreground", children: gap.name || t2("inventory.recipeGaps.unnamed") }),
2279
+ Number(gap.quantity) > 0 && /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
2280
+ " \xB7 ",
2281
+ gap.quantity
2282
+ ] }),
2283
+ gap.notes && /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
2284
+ " \xB7 ",
2285
+ gap.notes
2286
+ ] })
2287
+ ] }),
2288
+ /* @__PURE__ */ jsx(
2289
+ SearchSelect,
2290
+ {
2291
+ value: "",
2292
+ onChange: (id) => {
2293
+ if (id) void linkIngredient(index, id);
2294
+ },
2295
+ onSearch: searchProducts,
2296
+ placeholder: t2("inventory.recipeForm.searchIngredient"),
2297
+ allowCreate: true,
2298
+ createLabel: t2("inventory.recipeForm.createIngredient"),
2299
+ onCreate: async (q) => {
2300
+ const p = await quickCreate(q, "ingredient");
2301
+ if (p) await linkIngredient(index, p.id);
2302
+ }
2303
+ }
2304
+ )
2305
+ ] }, `${gap.name}-${index}`))
2306
+ ] })
2307
+ ] });
2308
+ }
2309
+ function readGaps(recipe) {
2310
+ const meta = recipe?.metadata;
2311
+ return {
2312
+ unmatchedIngredients: Array.isArray(meta?.unmatchedIngredients) ? meta.unmatchedIngredients : void 0,
2313
+ productHint: typeof meta?.productHint === "string" ? meta.productHint : void 0
2314
+ };
2315
+ }
2316
+ function DetailSkeleton2() {
1074
2317
  return /* @__PURE__ */ jsxs("div", { className: "space-y-5", children: [
1075
2318
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
1076
2319
  /* @__PURE__ */ jsx("div", { className: "h-12 w-12 rounded-xl bg-muted/40 animate-pulse" }),
@@ -1093,30 +2336,51 @@ function DetailSkeleton() {
1093
2336
  }
1094
2337
  function RecipeDetailView({ recipeId, onBack }) {
1095
2338
  const t2 = useTranslation();
2339
+ const { currency } = useInventoryConfig();
1096
2340
  const provider = useInventoryProvider();
1097
2341
  const [recipe, setRecipe] = useState(null);
1098
2342
  const [ingredients, setIngredients] = useState([]);
1099
2343
  const [loading, setLoading] = useState(true);
1100
- useEffect(() => {
1101
- setLoading(true);
1102
- Promise.all([
2344
+ const load = React3.useCallback(async () => {
2345
+ const [r, ings] = await Promise.all([
1103
2346
  provider.getRecipeById(recipeId),
1104
2347
  provider.getRecipeIngredients(recipeId)
1105
- ]).then(([r, ings]) => {
1106
- setRecipe(r);
1107
- setIngredients(ings.sort((a, b) => a.displayOrder - b.displayOrder));
1108
- setLoading(false);
1109
- });
1110
- }, [recipeId]);
2348
+ ]);
2349
+ setRecipe(r);
2350
+ setIngredients(ings.sort((a, b) => a.displayOrder - b.displayOrder));
2351
+ }, [recipeId, provider]);
2352
+ useEffect(() => {
2353
+ setLoading(true);
2354
+ void load().finally(() => setLoading(false));
2355
+ }, [load]);
2356
+ const totalCost = useMemo(() => recipeCost(ingredients), [ingredients]);
2357
+ const cmv = useMemo(() => cmvPercent(totalCost, recipe?.productSalePrice), [totalCost, recipe?.productSalePrice]);
2358
+ const gaps = useMemo(() => readGaps(recipe), [recipe]);
1111
2359
  if (loading) {
1112
2360
  return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
1113
- /* @__PURE__ */ jsx(Breadcrumb, { parent: t2("inventory.nav.recipes"), current: t2("inventory.recipeDetail.loading"), onBack }),
1114
- /* @__PURE__ */ jsx(DetailSkeleton, {})
2361
+ /* @__PURE__ */ jsx(
2362
+ SubpageHeader,
2363
+ {
2364
+ title: t2("inventory.recipeDetail.loading"),
2365
+ icon: "BookOpen",
2366
+ onBack,
2367
+ parentLabel: t2("inventory.nav.recipes")
2368
+ }
2369
+ ),
2370
+ /* @__PURE__ */ jsx(DetailSkeleton2, {})
1115
2371
  ] });
1116
2372
  }
1117
2373
  if (!recipe) {
1118
2374
  return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
1119
- /* @__PURE__ */ jsx(Breadcrumb, { parent: t2("inventory.nav.recipes"), onBack }),
2375
+ /* @__PURE__ */ jsx(
2376
+ SubpageHeader,
2377
+ {
2378
+ title: t2("inventory.recipeDetail.notFound"),
2379
+ icon: "BookOpen",
2380
+ onBack,
2381
+ parentLabel: t2("inventory.nav.recipes")
2382
+ }
2383
+ ),
1120
2384
  /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center justify-center py-16 text-center rounded-lg border-2 border-dashed border-muted", children: [
1121
2385
  /* @__PURE__ */ jsx(BookOpen, { className: "h-8 w-8 text-muted-foreground/30 mb-2" }),
1122
2386
  /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: t2("inventory.recipeDetail.notFound") }),
@@ -1149,16 +2413,28 @@ function RecipeDetailView({ recipeId, onBack }) {
1149
2413
  accessorKey: "notes",
1150
2414
  header: t2("inventory.recipeDetail.notes"),
1151
2415
  cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: getValue() || "\u2014" })
2416
+ },
2417
+ {
2418
+ id: "unitCost",
2419
+ header: () => /* @__PURE__ */ jsx("span", { className: "block text-right", children: t2("inventory.recipeDetail.unitCost") }),
2420
+ cell: ({ row }) => /* @__PURE__ */ jsx("span", { className: "block text-right text-muted-foreground tabular-nums", children: formatCurrency(ingredientUnitCost(row.original.costBasis), currency) })
2421
+ },
2422
+ {
2423
+ id: "cost",
2424
+ header: () => /* @__PURE__ */ jsx("span", { className: "block text-right", children: t2("inventory.recipeDetail.cost") }),
2425
+ cell: ({ row }) => /* @__PURE__ */ jsx("span", { className: "block text-right font-medium tabular-nums", children: formatCurrency(ingredientUnitCost(row.original.costBasis) * (row.original.quantity || 0), currency) })
1152
2426
  }
1153
2427
  ];
1154
2428
  return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
1155
- /* @__PURE__ */ jsx(Breadcrumb, { parent: t2("inventory.nav.recipes"), current: recipe.name, onBack }),
1156
- /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-4", children: [
1157
- /* @__PURE__ */ jsx("div", { className: "flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary", children: /* @__PURE__ */ jsx(BookOpen, { className: "h-6 w-6" }) }),
1158
- /* @__PURE__ */ jsxs("div", { className: "flex-1 min-w-0", children: [
1159
- /* @__PURE__ */ jsx("h1", { className: "text-2xl font-bold text-foreground", children: recipe.name }),
1160
- recipe.description && /* @__PURE__ */ jsx("p", { className: "text-muted-foreground mt-0.5 text-sm", children: recipe.description }),
1161
- /* @__PURE__ */ jsx("div", { className: "flex items-center gap-3 mt-2", children: recipe.isActive ? /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1 text-[10px] text-success", children: [
2429
+ /* @__PURE__ */ jsx(
2430
+ SubpageHeader,
2431
+ {
2432
+ title: recipe.name,
2433
+ subtitle: recipe.description,
2434
+ icon: "BookOpen",
2435
+ onBack,
2436
+ parentLabel: t2("inventory.nav.recipes"),
2437
+ actions: recipe.isActive ? /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1 text-[10px] text-success", children: [
1162
2438
  /* @__PURE__ */ jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-success" }),
1163
2439
  " ",
1164
2440
  t2("inventory.recipeDetail.active")
@@ -1166,11 +2442,15 @@ function RecipeDetailView({ recipeId, onBack }) {
1166
2442
  /* @__PURE__ */ jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-muted-foreground/30" }),
1167
2443
  " ",
1168
2444
  t2("inventory.recipeDetail.inactive")
1169
- ] }) })
1170
- ] })
1171
- ] }),
1172
- /* @__PURE__ */ jsx("div", { className: "border-t" }),
2445
+ ] })
2446
+ }
2447
+ ),
2448
+ /* @__PURE__ */ jsx(RecipeGapsPanel, { recipe, gaps, onResolved: load }),
1173
2449
  /* @__PURE__ */ jsxs("div", { className: "grid gap-4 lg:grid-cols-3", children: [
2450
+ /* @__PURE__ */ jsxs("div", { className: "lg:col-span-2", children: [
2451
+ /* @__PURE__ */ jsx("h3", { className: "text-sm font-semibold text-foreground mb-2", children: t2("inventory.recipeDetail.ingredientsTitle") }),
2452
+ ingredients.length === 0 ? /* @__PURE__ */ jsx("div", { className: "rounded-xl border p-8 text-center", children: /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: t2("inventory.recipeDetail.noIngredients") }) }) : /* @__PURE__ */ jsx(DataTable, { columns: ingredientColumns, data: ingredients, variant: "card" })
2453
+ ] }),
1174
2454
  /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
1175
2455
  /* @__PURE__ */ jsxs("div", { className: "rounded-xl border divide-y", children: [
1176
2456
  recipe.productName && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 px-4 py-3", children: [
@@ -1198,11 +2478,38 @@ function RecipeDetailView({ recipeId, onBack }) {
1198
2478
  /* @__PURE__ */ jsx(Layers, { className: "h-4 w-4 text-muted-foreground shrink-0" }),
1199
2479
  /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground flex-1", children: t2("inventory.recipeDetail.ingredientCount") }),
1200
2480
  /* @__PURE__ */ jsx("span", { className: "text-sm font-medium", children: ingredients.length })
2481
+ ] }),
2482
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 px-4 py-3", children: [
2483
+ /* @__PURE__ */ jsx(Coins, { className: "h-4 w-4 text-muted-foreground shrink-0" }),
2484
+ /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground flex-1", children: t2("inventory.recipeDetail.totalCost") }),
2485
+ /* @__PURE__ */ jsx("span", { className: "text-sm font-medium tabular-nums", children: formatCurrency(totalCost, currency) })
2486
+ ] }),
2487
+ recipe.yieldQuantity > 1 && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 px-4 py-3", children: [
2488
+ /* @__PURE__ */ jsx(Coins, { className: "h-4 w-4 text-muted-foreground shrink-0" }),
2489
+ /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground flex-1", children: t2("inventory.recipeDetail.costPerYield") }),
2490
+ /* @__PURE__ */ jsx("span", { className: "text-sm font-medium tabular-nums", children: formatCurrency(totalCost / recipe.yieldQuantity, currency) })
2491
+ ] }),
2492
+ recipe.productSalePrice != null && recipe.productSalePrice > 0 && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 px-4 py-3", children: [
2493
+ /* @__PURE__ */ jsx(Package, { className: "h-4 w-4 text-muted-foreground shrink-0" }),
2494
+ /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground flex-1", children: t2("inventory.recipeDetail.salePrice") }),
2495
+ /* @__PURE__ */ jsx("span", { className: "text-sm font-medium tabular-nums", children: formatCurrency(recipe.productSalePrice, currency) })
2496
+ ] }),
2497
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 px-4 py-3", children: [
2498
+ /* @__PURE__ */ jsx(Coins, { className: "h-4 w-4 text-muted-foreground shrink-0" }),
2499
+ /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground flex-1", children: t2("inventory.recipeDetail.cmv") }),
2500
+ cmv == null ? /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: t2("inventory.recipeDetail.cmvUnknown") }) : /* @__PURE__ */ jsxs("span", { className: `text-sm font-medium tabular-nums ${isCmvHigh(cmv) ? "text-destructive" : "text-success"}`, children: [
2501
+ cmv.toFixed(1),
2502
+ "%"
2503
+ ] })
1201
2504
  ] })
1202
2505
  ] }),
2506
+ isCmvHigh(cmv) && /* @__PURE__ */ jsxs("p", { className: "flex items-start gap-1.5 text-[11px] text-destructive", children: [
2507
+ /* @__PURE__ */ jsx(AlertTriangle, { className: "h-3.5 w-3.5 shrink-0 mt-px" }),
2508
+ t2("inventory.recipeDetail.cmvHigh", { threshold: String(CMV_ALERT_THRESHOLD) })
2509
+ ] }),
1203
2510
  recipe.instructions && /* @__PURE__ */ jsxs("div", { children: [
1204
2511
  /* @__PURE__ */ jsx("h3", { className: "text-sm font-semibold text-foreground mb-1", children: t2("inventory.recipeDetail.instructions") }),
1205
- /* @__PURE__ */ jsx("div", { className: "rounded-xl border bg-card shadow-sm px-4 py-3", children: /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground whitespace-pre-wrap leading-relaxed", children: recipe.instructions }) })
2512
+ /* @__PURE__ */ jsx("div", { className: "rounded-card border bg-card shadow-sm px-4 py-3", children: /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground whitespace-pre-wrap leading-relaxed", children: recipe.instructions }) })
1206
2513
  ] }),
1207
2514
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-4 text-[10px] text-muted-foreground/50", children: [
1208
2515
  /* @__PURE__ */ jsxs("span", { children: [
@@ -1219,7 +2526,13 @@ function RecipeDetailView({ recipeId, onBack }) {
1219
2526
  ] }),
1220
2527
  /* @__PURE__ */ jsxs("div", { className: "lg:col-span-2", children: [
1221
2528
  /* @__PURE__ */ jsx("h3", { className: "text-sm font-semibold text-foreground mb-2", children: t2("inventory.recipeDetail.ingredientsTitle") }),
1222
- ingredients.length === 0 ? /* @__PURE__ */ jsx("div", { className: "rounded-xl border p-8 text-center", children: /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: t2("inventory.recipeDetail.noIngredients") }) }) : /* @__PURE__ */ jsx(DataTable, { columns: ingredientColumns, data: ingredients, variant: "card" })
2529
+ ingredients.length === 0 ? /* @__PURE__ */ jsx("div", { className: "rounded-xl border p-8 text-center", children: /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: t2("inventory.recipeDetail.noIngredients") }) }) : /* @__PURE__ */ jsxs(Fragment, { children: [
2530
+ /* @__PURE__ */ jsx(DataTable, { columns: ingredientColumns, data: ingredients, variant: "card" }),
2531
+ /* @__PURE__ */ jsxs("div", { className: "flex items-baseline justify-end gap-3 px-4 py-2 text-sm", children: [
2532
+ /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: t2("inventory.recipeDetail.totalCost") }),
2533
+ /* @__PURE__ */ jsx("span", { className: "font-semibold tabular-nums", children: formatCurrency(totalCost, currency) })
2534
+ ] })
2535
+ ] })
1223
2536
  ] })
1224
2537
  ] })
1225
2538
  ] });
@@ -1297,7 +2610,7 @@ function buildNav(config, view, navigate, t2) {
1297
2610
  active: view.startsWith("products"),
1298
2611
  children: [
1299
2612
  { id: "products-new", label: t2("inventory.nav.new"), active: view === "products-new", onClick: () => navigate("products-new") },
1300
- { id: "products-list", label: t2("inventory.nav.list"), active: view === "products-list", onClick: () => navigate("products-list") }
2613
+ { id: "products-list", label: t2("inventory.nav.list"), active: view === "products-list" || view.startsWith("products-detail:") || view.startsWith("products-stock:"), onClick: () => navigate("products-list") }
1301
2614
  ]
1302
2615
  }] : [],
1303
2616
  {
@@ -1307,6 +2620,9 @@ function buildNav(config, view, navigate, t2) {
1307
2620
  children: [
1308
2621
  { id: "stock-entry", label: t2("inventory.nav.entry"), active: view === "stock-entry", onClick: () => navigate("stock-entry") },
1309
2622
  { id: "stock-exit", label: t2("inventory.nav.exit"), active: view === "stock-exit", onClick: () => navigate("stock-exit") },
2623
+ // Before History on purpose: the parent tab flattens to the LAST child,
2624
+ // and "Stock" has always landed on History.
2625
+ { id: "stock-counts", label: t2("inventory.nav.counts"), active: view === "stock-counts" || view.startsWith("stock-counts"), onClick: () => navigate("stock-counts") },
1310
2626
  { id: "stock-history", label: t2("inventory.nav.history"), active: view === "stock-history", onClick: () => navigate("stock-history") }
1311
2627
  ]
1312
2628
  }
@@ -1330,9 +2646,12 @@ function InventoryPage({ config, provider, store, registries }) {
1330
2646
  dashboard: 0,
1331
2647
  "products-list": 0,
1332
2648
  "products-new": 1,
2649
+ "products-detail": 1,
1333
2650
  "stock-entry": 1,
1334
2651
  "stock-exit": 1,
1335
2652
  "stock-history": 0,
2653
+ "stock-counts": 0,
2654
+ "stock-counts-new": 1,
1336
2655
  "recipes-list": 0,
1337
2656
  "recipes-new": 1,
1338
2657
  "recipes-detail": 1,
@@ -1391,26 +2710,45 @@ function InventoryPage({ config, provider, store, registries }) {
1391
2710
  /* @__PURE__ */ jsx("h1", { style: { fontSize: "20px", fontWeight: 600, margin: 0 }, children: t2("inventory.settingsPage.title") }),
1392
2711
  /* @__PURE__ */ jsx("p", { style: { color: "var(--muted-foreground, #6b7280)", margin: "4px 0 0", fontSize: "14px" }, children: t2("inventory.settingsPage.subtitle") })
1393
2712
  ] }),
1394
- /* @__PURE__ */ jsx(InventoryGeneralSettings, {})
2713
+ /* @__PURE__ */ jsx(
2714
+ PluginSettingsPanel,
2715
+ {
2716
+ title: t2("inventory.settingsPage.title"),
2717
+ generalSettings: /* @__PURE__ */ jsx(InventoryGeneralSettings, {}),
2718
+ registries,
2719
+ hostPluginId: "inventory",
2720
+ routeBase: "/inventory/settings"
2721
+ }
2722
+ )
1395
2723
  ] }) }) });
1396
2724
  }
1397
2725
  const renderView = createViewRouter([
1398
2726
  // Routes go with the module, not just the nav entry: leaving them mounted
1399
2727
  // keeps a second product form reachable by URL.
1400
2728
  ...config.modules.products ? [
1401
- { id: "products-list", render: () => /* @__PURE__ */ jsx(ProductListView, { onNew: () => navigate("products-new"), onEdit: (id) => navigate(`products-edit:${id}`) }) },
2729
+ { id: "products", render: () => /* @__PURE__ */ jsx(ProductListView, { onNew: () => navigate("products-new"), onOpen: (id) => navigate(`products-detail:${id}`) }) },
2730
+ { id: "products-list", render: () => /* @__PURE__ */ jsx(ProductListView, { onNew: () => navigate("products-new"), onOpen: (id) => navigate(`products-detail:${id}`) }) },
1402
2731
  { id: "products-new", render: () => /* @__PURE__ */ jsx(ProductCrudForm, { onSaved: () => navigate("products-list") }) },
1403
- { id: "products-edit", render: ({ id }) => /* @__PURE__ */ jsx(ProductCrudForm, { editId: id, onSaved: () => navigate("products-list") }) }
2732
+ { id: "products-edit", render: ({ id }) => /* @__PURE__ */ jsx(ProductCrudForm, { editId: id, onSaved: () => navigate(`products-detail:${id}`) }) },
2733
+ { id: "products-detail", render: ({ id }) => /* @__PURE__ */ jsx(ProductDetailView, { productId: id, onBack: () => navigate("products-list"), onEdit: () => navigate(`products-edit:${id}`) }) },
2734
+ // The tab writes `/products/<id>/stock` into the URL, so that link has to
2735
+ // come back to the same page with the tab open.
2736
+ { id: "products-stock", render: ({ id }) => /* @__PURE__ */ jsx(ProductDetailView, { productId: id, initialTab: "stock", onBack: () => navigate("products-list"), onEdit: () => navigate(`products-edit:${id}`) }) }
1404
2737
  ] : [],
1405
2738
  { id: "stock-entry", render: () => /* @__PURE__ */ jsx(StockMovementView, { defaultType: "entry", onSaved: () => navigate("stock-history") }) },
1406
2739
  { id: "stock-exit", render: () => /* @__PURE__ */ jsx(StockMovementView, { defaultType: "exit", onSaved: () => navigate("stock-history") }) },
1407
- { id: "stock-history", render: () => /* @__PURE__ */ jsx(MovementHistoryView, { onViewDetail: (id) => navigate(`stock-detail:${id}`) }) },
2740
+ { id: "stock-history", render: () => /* @__PURE__ */ jsx(MovementHistoryView, { onViewDetail: (id) => navigate(`stock-detail:${id}`), onNew: () => navigate("stock-entry"), onNewExit: () => navigate("stock-exit") }) },
1408
2741
  { id: "stock-detail", render: ({ id }) => {
1409
2742
  const movement = store.getState().movements.find((m) => m.id === id);
1410
2743
  return movement ? /* @__PURE__ */ jsx(StockMovementView, { defaultType: movement.movementType, viewMovement: movement, onSaved: () => navigate("stock-history") }) : /* @__PURE__ */ jsx(MovementHistoryView, { onViewDetail: (mid) => navigate(`stock-detail:${mid}`) });
1411
2744
  } },
1412
- { id: "recipes-list", render: () => /* @__PURE__ */ jsx(RecipesView, { onNew: () => navigate("recipes-new"), onView: (id) => navigate(`recipes-detail:${id}`) }) },
1413
- { id: "recipes-new", render: () => /* @__PURE__ */ jsx(RecipeFormView, { onSaved: (id) => id ? navigate(`recipes-detail:${id}`) : navigate("recipes-list") }) },
2745
+ // One route id for both: `/inventory/stock/counts/<uuid>` parses back as
2746
+ // `stock-counts:<id>`, not `stock-counts-detail:<id>`.
2747
+ { id: "stock-counts", render: ({ id }) => id ? /* @__PURE__ */ jsx(StockCountSessionView, { sessionId: id, onBack: () => navigate("stock-counts") }) : /* @__PURE__ */ jsx(StockCountsView, { onNew: () => navigate("stock-counts-new"), onOpen: (sid) => navigate(`stock-counts:${sid}`) }) },
2748
+ { id: "stock-counts-new", render: () => /* @__PURE__ */ jsx(StockCountFormView, { onOpened: (sid) => navigate(`stock-counts:${sid}`), onCancel: () => navigate("stock-counts") }) },
2749
+ { id: "recipes", render: () => /* @__PURE__ */ jsx(RecipesView, { onNew: () => navigate("recipes-new"), onView: (id) => navigate(`recipes-detail:${id}`), onNewForProduct: (pid) => navigate(`recipes-new:${pid}`) }) },
2750
+ { id: "recipes-list", render: () => /* @__PURE__ */ jsx(RecipesView, { onNew: () => navigate("recipes-new"), onView: (id) => navigate(`recipes-detail:${id}`), onNewForProduct: (pid) => navigate(`recipes-new:${pid}`) }) },
2751
+ { id: "recipes-new", render: ({ id }) => /* @__PURE__ */ jsx(RecipeFormView, { productId: id, onSaved: (rid) => rid ? navigate(`recipes-detail:${rid}`) : navigate("recipes-list") }) },
1414
2752
  { id: "recipes-detail", render: ({ id }) => /* @__PURE__ */ jsx(RecipeDetailView, { recipeId: id, onBack: () => navigate("recipes-list") }) },
1415
2753
  { id: "dashboard", render: () => /* @__PURE__ */ jsx(DashboardView, {}) }
1416
2754
  ], "dashboard");
@@ -1539,13 +2877,61 @@ function createStore() {
1539
2877
  locations: [
1540
2878
  { id: uid(), name: "Main Storage", description: "Primary storage area", isActive: true, tenantId: "mock-tenant", createdAt: now(), updatedAt: now() }
1541
2879
  ],
2880
+ suppliers: [
2881
+ { id: uid(), name: "Main Supplier" }
2882
+ ],
1542
2883
  recipes: [],
1543
- recipeIngredients: []
2884
+ recipeIngredients: [],
2885
+ recipeGroups: [],
2886
+ countSessions: [],
2887
+ countItems: []
1544
2888
  };
1545
2889
  }
1546
2890
  function createMockInventoryProvider() {
1547
2891
  const store = createStore();
1548
2892
  const tenantId = "mock-tenant";
2893
+ function withProduct(recipe) {
2894
+ const product = store.products.find((p) => p.id === recipe.productId);
2895
+ return { ...recipe, productName: product?.name ?? recipe.productName, productSalePrice: product?.salePrice };
2896
+ }
2897
+ function withCostBasis(ing) {
2898
+ const product = store.products.find((p) => p.id === ing.productId);
2899
+ if (!product) return ing;
2900
+ return {
2901
+ ...ing,
2902
+ productName: product.name ?? ing.productName,
2903
+ costBasis: {
2904
+ cost: product.costPrice ?? 0,
2905
+ conversionFactor: product.conversionFactor,
2906
+ purchaseUnitId: product.purchaseUnitId
2907
+ }
2908
+ };
2909
+ }
2910
+ function applyPositionDelta2(input, locationId, amount, mode = "add") {
2911
+ const slot = store.positions.find(
2912
+ (p) => p.productId === input.productId && p.stockLocationId === locationId && (p.batchNumber ?? null) === (input.batchNumber ?? null) && (p.expirationDate ?? null) === (input.expirationDate ?? null)
2913
+ );
2914
+ if (slot) {
2915
+ const current = slot.quantity;
2916
+ slot.quantity = mode === "set" ? amount : current + amount;
2917
+ slot.unitCost = input.unitCost ?? slot.unitCost;
2918
+ slot.updatedAt = now();
2919
+ return slot.quantity - current;
2920
+ }
2921
+ store.positions.push({
2922
+ id: uid(),
2923
+ productId: input.productId,
2924
+ quantity: amount,
2925
+ unitCost: input.unitCost ?? 0,
2926
+ stockLocationId: locationId,
2927
+ stockLocationName: store.locations.find((l) => l.id === locationId)?.name,
2928
+ batchNumber: input.batchNumber,
2929
+ expirationDate: input.expirationDate,
2930
+ tenantId,
2931
+ createdAt: now()
2932
+ });
2933
+ return amount;
2934
+ }
1549
2935
  const provider = {
1550
2936
  // --- Products ---
1551
2937
  async getProducts(query) {
@@ -1581,6 +2967,8 @@ function createMockInventoryProvider() {
1581
2967
  costPrice: input.costPrice ?? 0,
1582
2968
  salePrice: input.salePrice,
1583
2969
  measurementUnitId: input.measurementUnitId,
2970
+ purchaseUnitId: input.purchaseUnitId,
2971
+ conversionFactor: input.conversionFactor ?? 1,
1584
2972
  isActive: true,
1585
2973
  supplierId: input.supplierId,
1586
2974
  defaultLocationId: input.defaultLocationId,
@@ -1634,21 +3022,61 @@ function createMockInventoryProvider() {
1634
3022
  stockLocationName: location?.name,
1635
3023
  destinationLocationId: input.destinationLocationId,
1636
3024
  supplierId: input.supplierId,
3025
+ supplierName: store.suppliers.find((s) => s.id === input.supplierId)?.name,
1637
3026
  documentNumber: input.documentNumber,
1638
3027
  reason: input.reason,
1639
3028
  notes: input.notes,
3029
+ batchNumber: input.batchNumber,
3030
+ expirationDate: input.expirationDate,
1640
3031
  movementDate: input.movementDate ?? today(),
1641
3032
  tenantId,
1642
3033
  createdAt: now()
1643
3034
  };
1644
3035
  store.movements.push(movement);
3036
+ let stockDelta = input.movementType === "entry" ? input.quantity : input.movementType === "exit" || input.movementType === "loss" ? -input.quantity : 0;
3037
+ if (input.stockLocationId) {
3038
+ const slot = { ...input, unitCost };
3039
+ const mode = input.movementType === "adjustment" ? "set" : "add";
3040
+ const amount = mode === "set" ? input.quantity : input.movementType === "entry" ? input.quantity : -input.quantity;
3041
+ const applied = applyPositionDelta2(slot, input.stockLocationId, amount, mode);
3042
+ if (input.movementType === "adjustment") stockDelta = applied;
3043
+ if (input.movementType === "transfer" && input.destinationLocationId) {
3044
+ applyPositionDelta2(slot, input.destinationLocationId, input.quantity);
3045
+ stockDelta = 0;
3046
+ }
3047
+ } else if (input.movementType === "adjustment") {
3048
+ stockDelta = input.quantity - (product?.currentQuantity ?? 0);
3049
+ }
1645
3050
  if (product) {
1646
- if (input.movementType === "entry") product.currentQuantity += input.quantity;
1647
- else if (input.movementType === "exit" || input.movementType === "loss") product.currentQuantity -= input.quantity;
3051
+ product.currentQuantity += stockDelta;
1648
3052
  product.updatedAt = now();
1649
3053
  }
1650
3054
  return movement;
1651
3055
  },
3056
+ async reverseMovement(movementId) {
3057
+ const index = store.movements.findIndex((m) => m.id === movementId);
3058
+ if (index === -1) throw new Error(`Movement ${movementId} not found`);
3059
+ const movement = store.movements[index];
3060
+ const product = store.products.find((p) => p.id === movement.productId);
3061
+ if (product) {
3062
+ if (movement.movementType === "entry") product.currentQuantity -= movement.quantity;
3063
+ else if (movement.movementType === "exit" || movement.movementType === "loss") product.currentQuantity += movement.quantity;
3064
+ product.updatedAt = now();
3065
+ }
3066
+ if (movement.stockLocationId) {
3067
+ const slot = {
3068
+ productId: movement.productId,
3069
+ unitCost: movement.unitCost,
3070
+ batchNumber: movement.batchNumber,
3071
+ expirationDate: movement.expirationDate
3072
+ };
3073
+ applyPositionDelta2(slot, movement.stockLocationId, movement.movementType === "entry" ? -movement.quantity : movement.quantity);
3074
+ if (movement.movementType === "transfer" && movement.destinationLocationId) {
3075
+ applyPositionDelta2(slot, movement.destinationLocationId, -movement.quantity);
3076
+ }
3077
+ }
3078
+ store.movements.splice(index, 1);
3079
+ },
1652
3080
  // --- Stock Positions ---
1653
3081
  async getPositions(productId) {
1654
3082
  return store.positions.filter((p) => p.productId === productId);
@@ -1671,15 +3099,169 @@ function createMockInventoryProvider() {
1671
3099
  store.locations.push(location);
1672
3100
  return location;
1673
3101
  },
3102
+ // --- Suppliers ---
3103
+ async getSuppliers() {
3104
+ return store.suppliers;
3105
+ },
3106
+ // --- Categories ---
3107
+ async getCategories() {
3108
+ const seen = /* @__PURE__ */ new Map();
3109
+ for (const product of store.products) {
3110
+ if (!product.categoryId || seen.has(product.categoryId)) continue;
3111
+ seen.set(product.categoryId, {
3112
+ id: product.categoryId,
3113
+ name: product.categoryName ?? product.categoryId,
3114
+ isActive: true,
3115
+ tenantId,
3116
+ createdAt: now(),
3117
+ updatedAt: now()
3118
+ });
3119
+ }
3120
+ return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
3121
+ },
3122
+ // --- Stock counts ---
3123
+ async getCountSessions() {
3124
+ return [...store.countSessions].sort((a, b) => b.openedAt.localeCompare(a.openedAt)).map((session) => {
3125
+ const items = store.countItems.filter((i) => i.sessionId === session.id);
3126
+ return {
3127
+ ...session,
3128
+ itemCount: items.length,
3129
+ countedCount: items.filter((i) => i.countedQuantity !== void 0).length,
3130
+ divergentCount: items.filter(isDivergent).length
3131
+ };
3132
+ });
3133
+ },
3134
+ async getCountSession(id) {
3135
+ return store.countSessions.find((s) => s.id === id) ?? null;
3136
+ },
3137
+ async getCountItems(sessionId) {
3138
+ return store.countItems.filter((i) => i.sessionId === sessionId).sort((a, b) => (a.productName ?? "").localeCompare(b.productName ?? ""));
3139
+ },
3140
+ async openCountSession(input) {
3141
+ const location = store.locations.find((l) => l.id === input.stockLocationId);
3142
+ if (!location) throw new Error(`Stock location ${input.stockLocationId} not found`);
3143
+ const session = {
3144
+ id: uid(),
3145
+ reference: input.reference?.trim() || void 0,
3146
+ status: "open",
3147
+ stockLocationId: input.stockLocationId,
3148
+ stockLocationName: location.name,
3149
+ categoryId: input.categoryId,
3150
+ blind: input.blind ?? true,
3151
+ notes: input.notes,
3152
+ openedAt: now(),
3153
+ tenantId,
3154
+ createdAt: now(),
3155
+ updatedAt: now()
3156
+ };
3157
+ store.countSessions.push(session);
3158
+ for (const product of store.products) {
3159
+ if (!product.isActive || product.productType === "asset") continue;
3160
+ if (input.categoryId && product.categoryId !== input.categoryId) continue;
3161
+ const slots = store.positions.filter(
3162
+ (p) => p.productId === product.id && p.stockLocationId === input.stockLocationId
3163
+ );
3164
+ store.countItems.push({
3165
+ id: uid(),
3166
+ sessionId: session.id,
3167
+ productId: product.id,
3168
+ productName: product.name,
3169
+ productSku: product.sku,
3170
+ systemQuantity: slots.reduce((sum, p) => sum + (p.quantity ?? 0), 0),
3171
+ unitCost: slots[0]?.unitCost ?? product.costPrice ?? 0,
3172
+ tenantId,
3173
+ createdAt: now(),
3174
+ updatedAt: now()
3175
+ });
3176
+ }
3177
+ return session;
3178
+ },
3179
+ async saveCountEntry(itemId, quantity, notes) {
3180
+ const item = store.countItems.find((i) => i.id === itemId);
3181
+ if (!item) throw new Error(`Count item ${itemId} not found`);
3182
+ item.countedQuantity = quantity == null ? void 0 : quantity;
3183
+ item.countedAt = quantity == null ? void 0 : now();
3184
+ if (notes !== void 0) item.notes = notes || void 0;
3185
+ item.updatedAt = now();
3186
+ const session = store.countSessions.find((s) => s.id === item.sessionId);
3187
+ if (session && session.status === "open") session.status = "counting";
3188
+ return item;
3189
+ },
3190
+ async closeCountSession(sessionId, reason) {
3191
+ const session = store.countSessions.find((s) => s.id === sessionId);
3192
+ if (!session) throw new Error(`Count session ${sessionId} not found`);
3193
+ if (session.status === "closed") {
3194
+ return { sessionId, adjustmentsCreated: 0, alreadyClosed: true };
3195
+ }
3196
+ if (session.status === "cancelled") throw new Error("This count was cancelled");
3197
+ let emitted = 0;
3198
+ for (const item of store.countItems) {
3199
+ if (item.sessionId !== sessionId) continue;
3200
+ if (!isDivergent(item) || item.movementId) continue;
3201
+ const counted = item.countedQuantity;
3202
+ const delta = counted - item.systemQuantity;
3203
+ const movement = {
3204
+ id: uid(),
3205
+ productId: item.productId,
3206
+ productName: item.productName,
3207
+ quantity: counted,
3208
+ movementType: "adjustment",
3209
+ unitCost: item.unitCost,
3210
+ totalCost: item.unitCost * counted,
3211
+ stockLocationId: session.stockLocationId,
3212
+ stockLocationName: session.stockLocationName,
3213
+ reason: reason || "Stock count",
3214
+ movementDate: today(),
3215
+ metadata: {
3216
+ countSessionId: sessionId,
3217
+ countItemId: item.id,
3218
+ systemQuantity: item.systemQuantity,
3219
+ countedQuantity: counted,
3220
+ variance: delta
3221
+ },
3222
+ tenantId,
3223
+ createdAt: now()
3224
+ };
3225
+ store.movements.push(movement);
3226
+ item.movementId = movement.id;
3227
+ item.updatedAt = now();
3228
+ applyPositionDelta2({ productId: item.productId, unitCost: item.unitCost }, session.stockLocationId, delta);
3229
+ const product = store.products.find((p) => p.id === item.productId);
3230
+ if (product) {
3231
+ product.currentQuantity += delta;
3232
+ product.updatedAt = now();
3233
+ }
3234
+ emitted += 1;
3235
+ }
3236
+ session.status = "closed";
3237
+ session.closedAt = now();
3238
+ session.updatedAt = now();
3239
+ return { sessionId, adjustmentsCreated: emitted, alreadyClosed: false };
3240
+ },
3241
+ async cancelCountSession(sessionId) {
3242
+ const session = store.countSessions.find((s) => s.id === sessionId);
3243
+ if (!session) throw new Error(`Count session ${sessionId} not found`);
3244
+ if (session.status === "closed") throw new Error("A closed count cannot be cancelled");
3245
+ session.status = "cancelled";
3246
+ session.updatedAt = now();
3247
+ return session;
3248
+ },
1674
3249
  // --- Recipes ---
1675
3250
  async getRecipes() {
1676
- return store.recipes.filter((r) => r.isActive);
3251
+ return store.recipes.filter((r) => r.isActive).map(withProduct);
1677
3252
  },
1678
3253
  async getRecipeById(id) {
1679
- return store.recipes.find((r) => r.id === id) ?? null;
3254
+ const recipe = store.recipes.find((r) => r.id === id);
3255
+ return recipe ? withProduct(recipe) : null;
1680
3256
  },
1681
3257
  async getRecipeIngredients(recipeId) {
1682
- return store.recipeIngredients.filter((ri) => ri.recipeId === recipeId).sort((a, b) => a.displayOrder - b.displayOrder);
3258
+ return store.recipeIngredients.filter((ri) => ri.recipeId === recipeId).sort((a, b) => a.displayOrder - b.displayOrder).map(withCostBasis);
3259
+ },
3260
+ async getAllRecipeIngredients() {
3261
+ return store.recipeIngredients.map(withCostBasis);
3262
+ },
3263
+ async getRecipeGroups(recipeId) {
3264
+ return store.recipeGroups.filter((g) => g.recipeId === recipeId).sort((a, b) => a.displayOrder - b.displayOrder);
1683
3265
  },
1684
3266
  async createRecipe(input) {
1685
3267
  const product = store.products.find((p) => p.id === input.productId);
@@ -1717,6 +3299,30 @@ function createMockInventoryProvider() {
1717
3299
  }
1718
3300
  return recipe;
1719
3301
  },
3302
+ async updateRecipe(id, patch) {
3303
+ const recipe = store.recipes.find((r) => r.id === id);
3304
+ if (!recipe) throw new Error(`Recipe ${id} not found`);
3305
+ Object.assign(recipe, patch, { updatedAt: now() });
3306
+ return withProduct(recipe);
3307
+ },
3308
+ async addRecipeIngredient(recipeId, input) {
3309
+ const product = store.products.find((p) => p.id === input.productId);
3310
+ const ingredient = {
3311
+ id: uid(),
3312
+ recipeId,
3313
+ productId: input.productId,
3314
+ productName: product?.name,
3315
+ quantity: input.quantity,
3316
+ unitId: input.unitId,
3317
+ displayOrder: input.displayOrder ?? store.recipeIngredients.filter((i) => i.recipeId === recipeId).length,
3318
+ notes: input.notes,
3319
+ createdAt: now()
3320
+ };
3321
+ store.recipeIngredients.push(ingredient);
3322
+ const recipe = store.recipes.find((r) => r.id === recipeId);
3323
+ if (recipe) recipe.ingredientCount = store.recipeIngredients.filter((i) => i.recipeId === recipeId).length;
3324
+ return withCostBasis(ingredient);
3325
+ },
1720
3326
  // --- Summary ---
1721
3327
  async getSummary() {
1722
3328
  const active = store.products.filter((p) => p.isActive);
@@ -1742,17 +3348,6 @@ function createMockInventoryProvider() {
1742
3348
  return provider;
1743
3349
  }
1744
3350
 
1745
- // src/data/tables.ts
1746
- var T = {
1747
- stockLocations: "plg_inventory_stock_locations",
1748
- stockMovements: "plg_inventory_stock_movements",
1749
- stockPositions: "plg_inventory_stock_positions",
1750
- recipes: "plg_inventory_recipes",
1751
- recipeIngredients: "plg_inventory_recipe_ingredients",
1752
- measurementUnits: "plg_inventory_measurement_units",
1753
- productCategories: "plg_inventory_product_categories"
1754
- };
1755
-
1756
3351
  // src/data/supabase.ts
1757
3352
  function getTenantId() {
1758
3353
  return getActiveTenantId();
@@ -1774,8 +3369,69 @@ function camelToSnake(obj) {
1774
3369
  }
1775
3370
  return result;
1776
3371
  }
3372
+ var PRODUCT_SELECT = `*, ${T.productDetails}(*)`;
3373
+ var RECIPE_SELECT = "*, products(name, price)";
3374
+ var RECIPE_INGREDIENT_SELECT = `*, products(name, cost, ${T.productDetails}(conversion_factor, purchase_unit_id))`;
3375
+ function embedded(value) {
3376
+ const one = Array.isArray(value) ? value[0] : value;
3377
+ return one && typeof one === "object" ? one : void 0;
3378
+ }
3379
+ function withoutEmbeds(row, keys) {
3380
+ const copy = { ...row };
3381
+ for (const key of keys) delete copy[key];
3382
+ return copy;
3383
+ }
3384
+ function mapRecipeRow(row) {
3385
+ const recipe = snakeToCamel(withoutEmbeds(row, ["products"]));
3386
+ const product = embedded(row.products);
3387
+ if (product) {
3388
+ recipe.productName = product.name ?? recipe.productName;
3389
+ recipe.productSalePrice = product.price ?? void 0;
3390
+ }
3391
+ return recipe;
3392
+ }
3393
+ function mapRecipeIngredientRow(row) {
3394
+ const ing = snakeToCamel(withoutEmbeds(row, ["products", T.recipeGroups]));
3395
+ const group = embedded(row[T.recipeGroups]);
3396
+ if (group) ing.groupName = group.name;
3397
+ const product = embedded(row.products);
3398
+ if (product) {
3399
+ const details = embedded(product[T.productDetails]);
3400
+ ing.productName = product.name ?? ing.productName;
3401
+ ing.costBasis = {
3402
+ cost: Number(product.cost) || 0,
3403
+ conversionFactor: details?.conversion_factor ?? void 0,
3404
+ purchaseUnitId: details?.purchase_unit_id ?? void 0
3405
+ };
3406
+ }
3407
+ return ing;
3408
+ }
3409
+ var COUNT_SESSION_SELECT = `*, ${T.stockLocations}(name), ${T.productCategories}(name)`;
3410
+ function mapCountSessionRow(row) {
3411
+ const session = snakeToCamel(
3412
+ withoutEmbeds(row, [T.stockLocations, T.productCategories])
3413
+ );
3414
+ session.stockLocationName = embedded(row[T.stockLocations])?.name;
3415
+ session.categoryName = embedded(row[T.productCategories])?.name;
3416
+ session.blind = row.blind ?? true;
3417
+ return session;
3418
+ }
3419
+ function mapCountItemRow(row) {
3420
+ const item = snakeToCamel(withoutEmbeds(row, ["products"]));
3421
+ const product = embedded(row.products);
3422
+ if (product) {
3423
+ item.productName = product.name;
3424
+ item.productSku = product.sku ?? void 0;
3425
+ }
3426
+ item.systemQuantity = Number(row.system_quantity) || 0;
3427
+ item.countedQuantity = row.counted_quantity == null ? void 0 : Number(row.counted_quantity);
3428
+ item.unitCost = Number(row.unit_cost) || 0;
3429
+ return item;
3430
+ }
1777
3431
  function mapProductRow(row) {
1778
3432
  const meta = row.metadata ?? {};
3433
+ const rawDetails = row[T.productDetails];
3434
+ const d = (Array.isArray(rawDetails) ? rawDetails[0] : rawDetails) ?? {};
1779
3435
  return {
1780
3436
  id: row.id,
1781
3437
  name: row.name,
@@ -1789,6 +3445,13 @@ function mapProductRow(row) {
1789
3445
  maxQuantity: meta.maxQuantity,
1790
3446
  costPrice: row.cost ?? 0,
1791
3447
  salePrice: row.price,
3448
+ categoryId: d.category_id ?? void 0,
3449
+ measurementUnitId: d.measurement_unit_id ?? void 0,
3450
+ purchaseUnitId: d.purchase_unit_id ?? void 0,
3451
+ conversionFactor: d.conversion_factor ?? void 0,
3452
+ supplierId: d.supplier_id ?? void 0,
3453
+ defaultLocationId: d.default_location_id ?? void 0,
3454
+ purpose: d.purpose ?? void 0,
1792
3455
  isActive: row.is_active ?? true,
1793
3456
  imageUrl: row.image_url,
1794
3457
  metadata: meta,
@@ -1797,6 +3460,49 @@ function mapProductRow(row) {
1797
3460
  updatedAt: row.updated_at
1798
3461
  };
1799
3462
  }
3463
+ function productDetailsRow(input) {
3464
+ const row = {};
3465
+ if (input.categoryId !== void 0) row.category_id = input.categoryId || null;
3466
+ if (input.measurementUnitId !== void 0) row.measurement_unit_id = input.measurementUnitId || null;
3467
+ if (input.purchaseUnitId !== void 0) row.purchase_unit_id = input.purchaseUnitId || null;
3468
+ if (input.conversionFactor !== void 0) row.conversion_factor = input.conversionFactor ?? 1;
3469
+ if (input.supplierId !== void 0) row.supplier_id = input.supplierId || null;
3470
+ if (input.defaultLocationId !== void 0) row.default_location_id = input.defaultLocationId || null;
3471
+ if (input.purpose !== void 0) row.purpose = input.purpose || null;
3472
+ return Object.keys(row).length > 0 ? row : null;
3473
+ }
3474
+ async function applyPositionDelta(pub, tenantId, input, locationId, amount, mode = "add") {
3475
+ const slot = (qb) => qb.eq("product_id", input.productId).eq("stock_location_id", locationId).is("batch_number", input.batchNumber ?? null).is("expiration_date", input.expirationDate ?? null);
3476
+ const bump = async () => {
3477
+ const { data: existing, error } = await slot(
3478
+ pub.from(T.stockPositions).select("id, quantity")
3479
+ ).maybeSingle();
3480
+ if (error) throw new Error(error.message);
3481
+ if (!existing) return null;
3482
+ const current = Number(existing.quantity) || 0;
3483
+ const next = mode === "set" ? amount : current + amount;
3484
+ const { error: updateError } = await pub.from(T.stockPositions).update({ quantity: next, unit_cost: input.unitCost ?? 0 }).eq("id", existing.id);
3485
+ if (updateError) throw new Error(updateError.message);
3486
+ return next - current;
3487
+ };
3488
+ const bumped = await bump();
3489
+ if (bumped !== null) return bumped;
3490
+ const { error: insertError } = await pub.from(T.stockPositions).insert({
3491
+ tenant_id: tenantId,
3492
+ product_id: input.productId,
3493
+ stock_location_id: locationId,
3494
+ quantity: amount,
3495
+ unit_cost: input.unitCost ?? 0,
3496
+ batch_number: input.batchNumber ?? null,
3497
+ expiration_date: input.expirationDate ?? null
3498
+ });
3499
+ if (!insertError) return amount;
3500
+ if (insertError.code === "23505") {
3501
+ const retried = await bump();
3502
+ if (retried !== null) return retried;
3503
+ }
3504
+ throw new Error(insertError.message);
3505
+ }
1800
3506
  function createSupabaseInventoryProvider() {
1801
3507
  function getClients() {
1802
3508
  const supabase = getSupabaseClientOptional();
@@ -1807,9 +3513,11 @@ function createSupabaseInventoryProvider() {
1807
3513
  // --- Products (public.products) ---
1808
3514
  async getProducts(query) {
1809
3515
  const { core} = getClients();
1810
- let qb = core.from("products").select("*", { count: "exact" });
3516
+ const select = query.categoryId ? `*, ${T.productDetails}!inner(*)` : PRODUCT_SELECT;
3517
+ let qb = core.from("products").select(select, { count: "exact" });
1811
3518
  if (query.search) qb = qb.ilike("name", `%${query.search}%`);
1812
3519
  if (query.productType) qb = qb.eq("metadata->>productType", query.productType);
3520
+ if (query.categoryId) qb = qb.eq(`${T.productDetails}.category_id`, query.categoryId);
1813
3521
  if (query.isActive !== void 0) qb = qb.eq("is_active", query.isActive);
1814
3522
  if (query.lowStockOnly) qb = qb.lte("stock", 0);
1815
3523
  const page = query.page ?? 1;
@@ -1820,7 +3528,7 @@ function createSupabaseInventoryProvider() {
1820
3528
  },
1821
3529
  async getProductById(id) {
1822
3530
  const { core } = getClients();
1823
- const { data } = await core.from("products").select("*").eq("id", id).single();
3531
+ const { data } = await core.from("products").select(PRODUCT_SELECT).eq("id", id).single();
1824
3532
  return data ? mapProductRow(data) : null;
1825
3533
  },
1826
3534
  async createProduct(input) {
@@ -1841,10 +3549,16 @@ function createSupabaseInventoryProvider() {
1841
3549
  };
1842
3550
  const { data, error } = await core.from("products").insert(row).select().single();
1843
3551
  if (error) throw new Error(error.message);
1844
- return mapProductRow(data);
3552
+ const details = productDetailsRow(input);
3553
+ if (details) {
3554
+ const { error: detailsError } = await core.from(T.productDetails).upsert({ ...details, product_id: data.id, tenant_id: tenantId }, { onConflict: "product_id" });
3555
+ if (detailsError) throw new Error(detailsError.message);
3556
+ }
3557
+ return await provider.getProductById(data.id) ?? mapProductRow(data);
1845
3558
  },
1846
3559
  async updateProduct(id, partial) {
1847
3560
  const { core } = getClients();
3561
+ const tenantId = getTenantId();
1848
3562
  const row = {};
1849
3563
  if (partial.name !== void 0) row.name = partial.name;
1850
3564
  if (partial.description !== void 0) row.description = partial.description;
@@ -1854,12 +3568,27 @@ function createSupabaseInventoryProvider() {
1854
3568
  if (partial.minQuantity !== void 0) row.min_stock = partial.minQuantity;
1855
3569
  if (partial.isActive !== void 0) row.is_active = partial.isActive;
1856
3570
  if (partial.imageUrl !== void 0) row.image_url = partial.imageUrl;
1857
- if (partial.productType !== void 0 || partial.barcode !== void 0 || partial.brand !== void 0) {
1858
- row.metadata = { productType: partial.productType, barcode: partial.barcode, brand: partial.brand };
3571
+ const metaPatch = {};
3572
+ if (partial.productType !== void 0) metaPatch.productType = partial.productType;
3573
+ if (partial.barcode !== void 0) metaPatch.barcode = partial.barcode;
3574
+ if (partial.brand !== void 0) metaPatch.brand = partial.brand;
3575
+ if (partial.maxQuantity !== void 0) metaPatch.maxQuantity = partial.maxQuantity;
3576
+ if (Object.keys(metaPatch).length > 0) {
3577
+ const { data: current } = await core.from("products").select("metadata").eq("id", id).single();
3578
+ row.metadata = { ...current?.metadata ?? {}, ...metaPatch };
1859
3579
  }
1860
- const { data, error } = await core.from("products").update(row).eq("id", id).select().single();
1861
- if (error) throw new Error(error.message);
1862
- return mapProductRow(data);
3580
+ if (Object.keys(row).length > 0) {
3581
+ const { error } = await core.from("products").update(row).eq("id", id);
3582
+ if (error) throw new Error(error.message);
3583
+ }
3584
+ const details = productDetailsRow(partial);
3585
+ if (details) {
3586
+ const { error: detailsError } = await core.from(T.productDetails).upsert({ ...details, product_id: id, tenant_id: tenantId }, { onConflict: "product_id" });
3587
+ if (detailsError) throw new Error(detailsError.message);
3588
+ }
3589
+ const updated = await provider.getProductById(id);
3590
+ if (!updated) throw new Error(`Product ${id} not found after update`);
3591
+ return updated;
1863
3592
  },
1864
3593
  // --- Stock Movements (via view with product join — single query) ---
1865
3594
  async getMovements(query) {
@@ -1919,48 +3648,21 @@ function createSupabaseInventoryProvider() {
1919
3648
  const { data, error } = await pub.from(T.stockMovements).insert(row).select().single();
1920
3649
  if (error) throw new Error(error.message);
1921
3650
  const { data: product } = await core.from("products").select("id, name, stock").eq("id", input.productId).single();
1922
- if (product) {
1923
- const delta = input.movementType === "entry" ? input.quantity : -input.quantity;
1924
- if (input.movementType !== "adjustment" && input.movementType !== "transfer") {
1925
- await core.from("products").update({ stock: (product.stock ?? 0) + delta }).eq("id", input.productId);
1926
- }
1927
- }
3651
+ let stockDelta = input.movementType === "entry" ? input.quantity : input.movementType === "exit" || input.movementType === "loss" ? -input.quantity : 0;
1928
3652
  if (input.stockLocationId) {
1929
- try {
1930
- const posDelta = input.movementType === "entry" ? input.quantity : -input.quantity;
1931
- const { data: existing } = await pub.from(T.stockPositions).select("id, quantity").eq("product_id", input.productId).eq("stock_location_id", input.stockLocationId).maybeSingle();
1932
- if (existing) {
1933
- await pub.from(T.stockPositions).update({ quantity: (existing.quantity ?? 0) + posDelta, unit_cost: input.unitCost ?? 0 }).eq("id", existing.id);
1934
- } else {
1935
- await pub.from(T.stockPositions).insert({
1936
- tenant_id: tenantId,
1937
- product_id: input.productId,
1938
- stock_location_id: input.stockLocationId,
1939
- quantity: Math.max(0, posDelta),
1940
- unit_cost: input.unitCost ?? 0,
1941
- batch_number: input.batchNumber ?? null,
1942
- expiration_date: input.expirationDate ?? null
1943
- });
1944
- }
1945
- if (input.movementType === "transfer" && input.destinationLocationId) {
1946
- const { data: destExisting } = await pub.from(T.stockPositions).select("id, quantity").eq("product_id", input.productId).eq("stock_location_id", input.destinationLocationId).maybeSingle();
1947
- if (destExisting) {
1948
- await pub.from(T.stockPositions).update({ quantity: (destExisting.quantity ?? 0) + input.quantity, unit_cost: input.unitCost ?? 0 }).eq("id", destExisting.id);
1949
- } else {
1950
- await pub.from(T.stockPositions).insert({
1951
- tenant_id: tenantId,
1952
- product_id: input.productId,
1953
- stock_location_id: input.destinationLocationId,
1954
- quantity: input.quantity,
1955
- unit_cost: input.unitCost ?? 0,
1956
- batch_number: input.batchNumber ?? null,
1957
- expiration_date: input.expirationDate ?? null
1958
- });
1959
- }
1960
- }
1961
- } catch {
1962
- console.warn("Failed to update stock_positions \u2014 table may not exist yet");
3653
+ const mode = input.movementType === "adjustment" ? "set" : "add";
3654
+ const amount = mode === "set" ? input.quantity : input.movementType === "entry" ? input.quantity : -input.quantity;
3655
+ const applied = await applyPositionDelta(pub, tenantId, input, input.stockLocationId, amount, mode);
3656
+ if (input.movementType === "adjustment") stockDelta = applied;
3657
+ if (input.movementType === "transfer" && input.destinationLocationId) {
3658
+ await applyPositionDelta(pub, tenantId, input, input.destinationLocationId, input.quantity);
3659
+ stockDelta = 0;
1963
3660
  }
3661
+ } else if (input.movementType === "adjustment") {
3662
+ stockDelta = input.quantity - (Number(product?.stock) || 0);
3663
+ }
3664
+ if (product && stockDelta !== 0) {
3665
+ await core.from("products").update({ stock: (Number(product.stock) || 0) + stockDelta }).eq("id", input.productId);
1964
3666
  }
1965
3667
  const movement = snakeToCamel(data);
1966
3668
  movement.productName = product?.name;
@@ -1972,13 +3674,62 @@ function createSupabaseInventoryProvider() {
1972
3674
  const { data: loc } = await pub.from(T.stockLocations).select("name").eq("id", input.destinationLocationId).single();
1973
3675
  movement.destinationLocationName = loc?.name;
1974
3676
  }
3677
+ if (input.supplierId) {
3678
+ const { data: supplier } = await core.from("people").select("name").eq("id", input.supplierId).single();
3679
+ movement.supplierName = supplier?.name;
3680
+ }
1975
3681
  return movement;
1976
3682
  },
3683
+ /**
3684
+ * Undo a movement: the ledger row goes and the stock it moved comes back.
3685
+ * Position and on-hand are unwound with the same rules createMovement
3686
+ * applied, so a reversal cannot leave the two disagreeing.
3687
+ */
3688
+ async reverseMovement(movementId) {
3689
+ const { core, pub } = getClients();
3690
+ const tenantId = getTenantId();
3691
+ const { data: row, error } = await pub.from(T.stockMovements).select("*").eq("id", movementId).single();
3692
+ if (error) throw new Error(error.message);
3693
+ if (!row) throw new Error(`Movement ${movementId} not found`);
3694
+ const movementType = row.movement_type;
3695
+ const quantity = Number(row.quantity) || 0;
3696
+ const slot = {
3697
+ productId: row.product_id,
3698
+ unitCost: row.unit_cost ?? 0,
3699
+ batchNumber: row.batch_number ?? void 0,
3700
+ expirationDate: row.expiration_date ?? void 0
3701
+ };
3702
+ const { data: deleted, error: deleteError } = await pub.from(T.stockMovements).delete().eq("id", movementId).select("id");
3703
+ if (deleteError) throw new Error(deleteError.message);
3704
+ if (!deleted || deleted.length === 0) throw new Error(`Movement ${movementId} could not be deleted`);
3705
+ if (movementType !== "adjustment" && movementType !== "transfer") {
3706
+ const { data: product } = await core.from("products").select("stock").eq("id", row.product_id).single();
3707
+ if (product) {
3708
+ const delta = movementType === "entry" ? -quantity : quantity;
3709
+ await core.from("products").update({ stock: (product.stock ?? 0) + delta }).eq("id", row.product_id);
3710
+ }
3711
+ }
3712
+ if (row.stock_location_id) {
3713
+ await applyPositionDelta(pub, tenantId, slot, row.stock_location_id, movementType === "entry" ? -quantity : quantity);
3714
+ if (movementType === "transfer" && row.destination_location_id) {
3715
+ await applyPositionDelta(pub, tenantId, slot, row.destination_location_id, -quantity);
3716
+ }
3717
+ }
3718
+ },
1977
3719
  // --- Stock Positions ---
1978
3720
  async getPositions(productId) {
1979
3721
  const { pub } = getClients();
1980
3722
  const { data } = await pub.from(T.stockPositions).select("*").eq("product_id", productId);
1981
- return (data ?? []).map((r) => snakeToCamel(r));
3723
+ const positions = (data ?? []).map((r) => snakeToCamel(r));
3724
+ const locationIds = [...new Set(positions.map((p) => p.stockLocationId).filter(Boolean))];
3725
+ if (locationIds.length > 0) {
3726
+ const { data: locs } = await pub.from(T.stockLocations).select("id, name").in("id", locationIds);
3727
+ const byId = new Map((locs ?? []).map((l) => [l.id, l.name]));
3728
+ for (const p of positions) {
3729
+ if (p.stockLocationId) p.stockLocationName = byId.get(p.stockLocationId);
3730
+ }
3731
+ }
3732
+ return positions;
1982
3733
  },
1983
3734
  // --- Stock Locations ---
1984
3735
  async getLocations() {
@@ -1992,20 +3743,133 @@ function createSupabaseInventoryProvider() {
1992
3743
  const { data } = await pub.from(T.stockLocations).insert({ ...camelToSnake(input), tenant_id: tenantId }).select().single();
1993
3744
  return snakeToCamel(data);
1994
3745
  },
3746
+ // --- Suppliers (same rows the suppliers registry edits) ---
3747
+ async getSuppliers() {
3748
+ const { core } = getClients();
3749
+ const { data } = await core.from("people").select("id, name, document_number, phone").eq("kind", "supplier").eq("is_active", true).order("name").limit(200);
3750
+ return (data ?? []).map((r) => ({
3751
+ id: r.id,
3752
+ name: r.name,
3753
+ documentNumber: r.document_number ?? void 0,
3754
+ phone: r.phone ?? void 0
3755
+ }));
3756
+ },
3757
+ // --- Categories ---
3758
+ async getCategories() {
3759
+ const { pub } = getClients();
3760
+ const { data } = await pub.from(T.productCategories).select("*").eq("is_active", true).order("name");
3761
+ return (data ?? []).map((r) => snakeToCamel(r));
3762
+ },
3763
+ // --- Stock counts ---
3764
+ async getCountSessions() {
3765
+ const { pub } = getClients();
3766
+ const { data } = await pub.from(T.countSessions).select(COUNT_SESSION_SELECT).order("opened_at", { ascending: false }).limit(100);
3767
+ const sessions = (data ?? []).map(mapCountSessionRow);
3768
+ if (sessions.length === 0) return sessions;
3769
+ const { data: lines } = await pub.from(T.countItems).select("session_id, system_quantity, counted_quantity").in("session_id", sessions.map((s) => s.id));
3770
+ const tally = /* @__PURE__ */ new Map();
3771
+ for (const line of lines ?? []) {
3772
+ const bucket = tally.get(line.session_id) ?? { total: 0, counted: 0, divergent: 0 };
3773
+ bucket.total += 1;
3774
+ if (line.counted_quantity != null) {
3775
+ bucket.counted += 1;
3776
+ if (Number(line.counted_quantity) !== Number(line.system_quantity)) bucket.divergent += 1;
3777
+ }
3778
+ tally.set(line.session_id, bucket);
3779
+ }
3780
+ for (const session of sessions) {
3781
+ const bucket = tally.get(session.id);
3782
+ session.itemCount = bucket?.total ?? 0;
3783
+ session.countedCount = bucket?.counted ?? 0;
3784
+ session.divergentCount = bucket?.divergent ?? 0;
3785
+ }
3786
+ return sessions;
3787
+ },
3788
+ async getCountSession(id) {
3789
+ const { pub } = getClients();
3790
+ const { data } = await pub.from(T.countSessions).select(COUNT_SESSION_SELECT).eq("id", id).maybeSingle();
3791
+ return data ? mapCountSessionRow(data) : null;
3792
+ },
3793
+ async getCountItems(sessionId) {
3794
+ const { pub } = getClients();
3795
+ const { data } = await pub.from(T.countItems).select("*, products(name, sku)").eq("session_id", sessionId);
3796
+ const items = (data ?? []).map(mapCountItemRow);
3797
+ return items.sort((a, b) => (a.productName ?? "").localeCompare(b.productName ?? ""));
3798
+ },
3799
+ async openCountSession(input) {
3800
+ const { pub } = getClients();
3801
+ const { data, error } = await pub.rpc("inventory_open_count_session", {
3802
+ p_stock_location_id: input.stockLocationId,
3803
+ p_category_id: input.categoryId ?? null,
3804
+ p_reference: input.reference ?? null,
3805
+ p_blind: input.blind ?? true,
3806
+ p_notes: input.notes ?? null
3807
+ });
3808
+ if (error) throw new Error(error.message);
3809
+ const session = await provider.getCountSession(data);
3810
+ if (!session) throw new Error("Count session could not be read after opening");
3811
+ return session;
3812
+ },
3813
+ async saveCountEntry(itemId, quantity, notes) {
3814
+ const { pub } = getClients();
3815
+ const row = {
3816
+ counted_quantity: quantity,
3817
+ counted_at: quantity == null ? null : (/* @__PURE__ */ new Date()).toISOString(),
3818
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
3819
+ };
3820
+ if (notes !== void 0) row.notes = notes || null;
3821
+ const { data, error } = await pub.from(T.countItems).update(row).eq("id", itemId).select("*, products(name, sku)").single();
3822
+ if (error) throw new Error(error.message);
3823
+ await pub.from(T.countSessions).update({ status: "counting", updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", data.session_id).eq("status", "open");
3824
+ return mapCountItemRow(data);
3825
+ },
3826
+ async closeCountSession(sessionId, reason) {
3827
+ const { pub } = getClients();
3828
+ const { data, error } = await pub.rpc("inventory_close_count_session", {
3829
+ p_session_id: sessionId,
3830
+ p_reason: reason ?? null
3831
+ });
3832
+ if (error) throw new Error(error.message);
3833
+ const result = data ?? {};
3834
+ if (result.ok === false) throw new Error(result.error ?? "Could not close the count");
3835
+ return {
3836
+ sessionId,
3837
+ adjustmentsCreated: Number(result.adjustments_created) || 0,
3838
+ alreadyClosed: result.already_closed === true
3839
+ };
3840
+ },
3841
+ async cancelCountSession(sessionId) {
3842
+ const { pub } = getClients();
3843
+ const { error } = await pub.from(T.countSessions).update({ status: "cancelled", updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", sessionId).neq("status", "closed");
3844
+ if (error) throw new Error(error.message);
3845
+ const session = await provider.getCountSession(sessionId);
3846
+ if (!session) throw new Error(`Count session ${sessionId} not found`);
3847
+ return session;
3848
+ },
1995
3849
  // --- Recipes ---
1996
3850
  async getRecipes() {
1997
3851
  const { pub } = getClients();
1998
- const { data } = await pub.from(T.recipes).select("*").eq("is_active", true).order("name");
1999
- return (data ?? []).map((r) => snakeToCamel(r));
3852
+ const { data } = await pub.from(T.recipes).select(RECIPE_SELECT).eq("is_active", true).order("name");
3853
+ return (data ?? []).map(mapRecipeRow);
2000
3854
  },
2001
3855
  async getRecipeById(id) {
2002
3856
  const { pub } = getClients();
2003
- const { data } = await pub.from(T.recipes).select("*").eq("id", id).single();
2004
- return data ? snakeToCamel(data) : null;
3857
+ const { data } = await pub.from(T.recipes).select(RECIPE_SELECT).eq("id", id).single();
3858
+ return data ? mapRecipeRow(data) : null;
2005
3859
  },
2006
3860
  async getRecipeIngredients(recipeId) {
2007
3861
  const { pub } = getClients();
2008
- const { data } = await pub.from(T.recipeIngredients).select("*").eq("recipe_id", recipeId).order("display_order");
3862
+ const { data } = await pub.from(T.recipeIngredients).select(`${RECIPE_INGREDIENT_SELECT}, ${T.recipeGroups}(name)`).eq("recipe_id", recipeId).order("display_order");
3863
+ return (data ?? []).map(mapRecipeIngredientRow);
3864
+ },
3865
+ async getAllRecipeIngredients() {
3866
+ const { pub } = getClients();
3867
+ const { data } = await pub.from(T.recipeIngredients).select(RECIPE_INGREDIENT_SELECT).order("display_order");
3868
+ return (data ?? []).map(mapRecipeIngredientRow);
3869
+ },
3870
+ async getRecipeGroups(recipeId) {
3871
+ const { pub } = getClients();
3872
+ const { data } = await pub.from(T.recipeGroups).select("*").eq("recipe_id", recipeId).order("display_order");
2009
3873
  return (data ?? []).map((r) => snakeToCamel(r));
2010
3874
  },
2011
3875
  async createRecipe(input) {
@@ -2020,6 +3884,33 @@ function createSupabaseInventoryProvider() {
2020
3884
  }
2021
3885
  return snakeToCamel(recipe);
2022
3886
  },
3887
+ async updateRecipe(id, patch) {
3888
+ const { pub } = getClients();
3889
+ const row = {};
3890
+ if (patch.name !== void 0) row.name = patch.name;
3891
+ if (patch.description !== void 0) row.description = patch.description;
3892
+ if (patch.productId !== void 0) row.product_id = patch.productId || null;
3893
+ if (patch.yieldQuantity !== void 0) row.yield_quantity = patch.yieldQuantity;
3894
+ if (patch.preparationTimeMinutes !== void 0) row.preparation_time_minutes = patch.preparationTimeMinutes;
3895
+ if (patch.instructions !== void 0) row.instructions = patch.instructions;
3896
+ if (patch.isActive !== void 0) row.is_active = patch.isActive;
3897
+ if (patch.metadata !== void 0) row.metadata = patch.metadata;
3898
+ if (Object.keys(row).length > 0) {
3899
+ row.updated_at = (/* @__PURE__ */ new Date()).toISOString();
3900
+ const { error } = await pub.from(T.recipes).update(row).eq("id", id);
3901
+ if (error) throw new Error(error.message);
3902
+ }
3903
+ const updated = await provider.getRecipeById(id);
3904
+ if (!updated) throw new Error(`Recipe ${id} not found after update`);
3905
+ return updated;
3906
+ },
3907
+ async addRecipeIngredient(recipeId, input) {
3908
+ const { pub } = getClients();
3909
+ const tenantId = getTenantId();
3910
+ const { data, error } = await pub.from(T.recipeIngredients).insert({ ...camelToSnake(input), recipe_id: recipeId, tenant_id: tenantId }).select(RECIPE_INGREDIENT_SELECT).single();
3911
+ if (error) throw new Error(error.message);
3912
+ return mapRecipeIngredientRow(data);
3913
+ },
2023
3914
  // --- Summary ---
2024
3915
  async getSummary() {
2025
3916
  const { core, pub } = getClients();
@@ -2260,6 +4151,23 @@ var en = {
2260
4151
  "inventory.dashboard.totalProducts": "Total Products",
2261
4152
  "inventory.dashboard.totalValue": "Total inventory value",
2262
4153
  "inventory.history.noMovements": "No movements recorded",
4154
+ "inventory.history.columnDate": "Date",
4155
+ "inventory.history.columnProduct": "Product",
4156
+ "inventory.history.columnType": "Type",
4157
+ "inventory.history.columnLocation": "Location",
4158
+ "inventory.history.columnQty": "Qty",
4159
+ "inventory.history.columnTotal": "Total",
4160
+ "inventory.history.newEntry": "New entry",
4161
+ "inventory.history.newExit": "Exit",
4162
+ "inventory.nav.recipes": "Recipes",
4163
+ "registry.suppliers": "Suppliers",
4164
+ "registry.suppliers.description": "Who you buy from",
4165
+ "registry.product-categories": "Categories",
4166
+ "registry.product-categories.description": "How the catalogue is grouped",
4167
+ "registry.stock-locations": "Stock locations",
4168
+ "registry.stock-locations.description": "Where stock physically sits \u2014 cold room, dry store, freezer",
4169
+ "registry.measurement-units": "Units of measure",
4170
+ "registry.measurement-units.description": "How each item is counted",
2263
4171
  "inventory.history.searchPlaceholder": "Search movements...",
2264
4172
  "inventory.history.subtitle": "All stock movements",
2265
4173
  "inventory.history.title": "Stock History",
@@ -2343,6 +4251,21 @@ var en = {
2343
4251
  "inventory.stock.transferLabel": "Transfer",
2344
4252
  "inventory.stock.unitCost": "Unit Cost",
2345
4253
  "inventory.stock.unitCostLabel": "Unit Cost",
4254
+ "inventory.stock.searchSupplier": "Search supplier...",
4255
+ "inventory.stock.noSuppliers": "No supplier registered",
4256
+ "inventory.productDetail.stockTab": "Stock",
4257
+ "inventory.productDetail.positionsTitle": "Stock by location",
4258
+ "inventory.productDetail.onHand": "On hand",
4259
+ "inventory.productDetail.noPositions": "No stock in any location",
4260
+ "inventory.productDetail.historyTitle": "Movement history",
4261
+ "inventory.productDetail.undoLast": "Undo last movement",
4262
+ "inventory.productDetail.undoTitle": "Undo the last movement?",
4263
+ "inventory.productDetail.undoDescription": "{{type}} of {{quantity}} on {{date}} leaves the history, and the stock it moved goes back.",
4264
+ "inventory.productDetail.undoConfirm": "Undo",
4265
+ "inventory.productDetail.undone": "Movement undone",
4266
+ "inventory.productDetail.undoFailed": "Could not undo the movement",
4267
+ "inventory.productDetail.notFound": "Product not found",
4268
+ "inventory.productDetail.backToList": "Back to products",
2346
4269
  "inventory.onboarding.welcome": "Welcome to Inventory",
2347
4270
  "inventory.onboarding.description": "Track products, manage stock levels, and monitor movements across your business.",
2348
4271
  "inventory.onboarding.skip": "Skip setup",
@@ -2415,6 +4338,31 @@ var en = {
2415
4338
  "inventory.productForm.maxQuantity": "Maximum Quantity",
2416
4339
  "inventory.productForm.maxQuantityHint": "Max capacity for this product",
2417
4340
  "inventory.productForm.optional": "Optional",
4341
+ "inventory.productForm.organization": "Organisation",
4342
+ "inventory.productForm.organizationDesc": "Category, unit, supplier and default storage location",
4343
+ "inventory.productForm.category": "Category",
4344
+ "inventory.productForm.measurementUnit": "Unit of measure",
4345
+ "inventory.productForm.measurementUnitHint": "How this item is counted in stock",
4346
+ "inventory.productForm.supplier": "Default supplier",
4347
+ "inventory.productForm.defaultLocation": "Default location",
4348
+ "inventory.movementType.entry": "Entry",
4349
+ "inventory.movementType.exit": "Exit",
4350
+ "inventory.movementType.adjustment": "Adjustment",
4351
+ "inventory.movementType.transfer": "Transfer",
4352
+ "inventory.movementType.loss": "Loss",
4353
+ "inventory.recipeForm.group": "Stage",
4354
+ "inventory.recipeForm.ungrouped": "No stage",
4355
+ "inventory.productList.bulkActivate": "Activate",
4356
+ "inventory.productList.bulkActivated": "{{count}} product(s) activated",
4357
+ "inventory.productList.bulkDeactivate": "Deactivate",
4358
+ "inventory.productList.bulkDeactivated": "{{count}} product(s) deactivated",
4359
+ "inventory.productList.bulkFailed": "{{count}} product(s) could not be updated",
4360
+ "inventory.productList.bulkNothingToDo": "The selected products are already in that state.",
4361
+ "inventory.productList.filterActive": "Active",
4362
+ "inventory.productList.filterAllStatuses": "Active and inactive",
4363
+ "inventory.productList.filterInactive": "Inactive",
4364
+ "inventory.productList.filterStatus": "Status",
4365
+ "inventory.productList.inactive": "Inactive",
2418
4366
  "inventory.productList.title": "Products",
2419
4367
  "inventory.productList.subtitle": "{{count}} products",
2420
4368
  "inventory.productList.product": "Product",
@@ -2423,6 +4371,14 @@ var en = {
2423
4371
  "inventory.productList.cost": "Cost",
2424
4372
  "inventory.productList.value": "Value",
2425
4373
  "inventory.productList.searchPlaceholder": "Search products...",
4374
+ "inventory.productList.aiHint": "Describe the product and I create it.",
4375
+ "inventory.productList.askPlaceholder": "Describe the product\u2026",
4376
+ "inventory.productList.askPersona": "Product intake",
4377
+ "inventory.productList.askPitch": "Tell me the product you want to add.",
4378
+ "inventory.productList.askDescription": "Name, price, cost, SKU, kind \u2014 tell me what you know, however you say it, and I fill in the rest sensibly. You check before it saves.",
4379
+ "inventory.productList.askSuggestion.0": "Black tee S/M/L, $79, cost $28",
4380
+ "inventory.productList.askSuggestion.1": "Add 5 sample products so I can test the store",
4381
+ "inventory.productList.askSuggestion.2": "A bundle of the two best sellers",
2426
4382
  "inventory.productList.newProduct": "New Product",
2427
4383
  "inventory.productList.empty": "No products yet",
2428
4384
  "inventory.productList.createFirst": "Create your first product",
@@ -2483,7 +4439,96 @@ var en = {
2483
4439
  "inventory.movements.title": "Movements",
2484
4440
  "inventory.movements.in": "Stock In",
2485
4441
  "inventory.movements.out": "Stock Out",
2486
- "inventory.settings.title": "Inventory Settings"
4442
+ "inventory.settings.title": "Inventory Settings",
4443
+ "inventory.recipeDetail.cost": "Cost",
4444
+ "inventory.recipeDetail.unitCost": "Unit cost",
4445
+ "inventory.recipeDetail.totalCost": "Recipe cost",
4446
+ "inventory.recipeDetail.costPerYield": "Cost per unit produced",
4447
+ "inventory.recipeDetail.salePrice": "Sale price",
4448
+ "inventory.recipeDetail.cmv": "Food cost %",
4449
+ "inventory.recipeDetail.cmvUnknown": "No sale price",
4450
+ "inventory.recipeDetail.cmvHigh": "Above {{threshold}}% \u2014 this dish is eating the margin.",
4451
+ "inventory.recipeGaps.title": "Not linked to your catalogue",
4452
+ "inventory.recipeGaps.description": "The assistant suggested these but could not match them. Pick the equivalent item or create it.",
4453
+ "inventory.recipeGaps.produces": "Produces:",
4454
+ "inventory.recipeGaps.unnamed": "Unnamed ingredient",
4455
+ "inventory.recipeGaps.linkFailed": "Could not link this item",
4456
+ "inventory.recipeGaps.createFailed": "Could not create the product",
4457
+ "inventory.recipes.entity": "Recipe",
4458
+ "inventory.recipes.colProduct": "Product",
4459
+ "inventory.recipes.colIngredients": "Ingredients",
4460
+ "inventory.recipes.colYield": "Yield",
4461
+ "inventory.recipes.colPrep": "Prep",
4462
+ "inventory.recipes.colCost": "Cost",
4463
+ "inventory.recipes.colSale": "Sale",
4464
+ "inventory.recipes.colCmv": "Food cost %",
4465
+ "inventory.recipes.noSheet": "No recipe sheet",
4466
+ "inventory.recipes.searchPlaceholder": "Search menu items...",
4467
+ "inventory.recipes.newByHand": "Fill it in by hand",
4468
+ "inventory.recipes.newByHandHint": "Open the form and enter the ingredients yourself",
4469
+ "inventory.recipes.aiHint": "Describe the dish and get the sheet built with your own ingredients",
4470
+ "inventory.recipes.askPlaceholder": "e.g. recipe for smoked hump with cheese, 400g, serves 3",
4471
+ "inventory.recipes.askPersona": "Recipe sheets",
4472
+ "inventory.recipes.askPitch": "Let's cost your menu",
4473
+ "inventory.recipes.askDescription": "Describe the dish and I build the sheet from the ingredients you already have, with cost and food cost %.",
4474
+ "inventory.recipes.askSuggestion.0": "Build the recipe sheet for the smoked hump with cheese",
4475
+ "inventory.recipes.askSuggestion.1": "Which menu items still have no recipe sheet?",
4476
+ "inventory.recipes.askSuggestion.2": "Which dishes are above 35% food cost?",
4477
+ "inventory.nav.counts": "Counts",
4478
+ "inventory.counts.title": "Stock counts",
4479
+ "inventory.counts.entity": "Stock count",
4480
+ "inventory.counts.colReference": "Count",
4481
+ "inventory.counts.colOpened": "Opened",
4482
+ "inventory.counts.colStatus": "Status",
4483
+ "inventory.counts.colProgress": "Counted",
4484
+ "inventory.counts.colDivergent": "Divergences",
4485
+ "inventory.counts.status.open": "Open",
4486
+ "inventory.counts.status.counting": "Counting",
4487
+ "inventory.counts.status.closed": "Closed",
4488
+ "inventory.counts.status.cancelled": "Cancelled",
4489
+ "inventory.counts.progress": "{{counted}} of {{total}}",
4490
+ "inventory.counts.newCount": "New count",
4491
+ "inventory.counts.searchPlaceholder": "Search counts...",
4492
+ "inventory.counts.empty": "No counts yet",
4493
+ "inventory.counts.formTitle": "Open a count",
4494
+ "inventory.counts.formSubtitle": "What the system believes is frozen the moment you open it \u2014 the variance describes the shelf, not the clock.",
4495
+ "inventory.counts.location": "Stock location",
4496
+ "inventory.counts.pickLocation": "Pick a location",
4497
+ "inventory.counts.category": "Category",
4498
+ "inventory.counts.allCategories": "Every category",
4499
+ "inventory.counts.reference": "Name",
4500
+ "inventory.counts.referencePlaceholder": "e.g. Cold room, end of August",
4501
+ "inventory.counts.blind": "Blind count",
4502
+ "inventory.counts.blindHint": "Hides the system quantity while counting, so you write what is on the shelf instead of confirming what is on the screen.",
4503
+ "inventory.counts.blindLine": "Blind \u2014 system quantity hidden",
4504
+ "inventory.counts.notes": "Notes",
4505
+ "inventory.counts.notesPlaceholder": "Anything the next person should know",
4506
+ "inventory.counts.open": "Open the count",
4507
+ "inventory.counts.opening": "Opening...",
4508
+ "inventory.counts.openFailed": "Could not open the count",
4509
+ "inventory.counts.cancel": "Cancel",
4510
+ "inventory.counts.notFound": "Count not found",
4511
+ "inventory.counts.noItems": "This count has no products",
4512
+ "inventory.counts.systemQty": "System:",
4513
+ "inventory.counts.notCounted": "Not counted",
4514
+ "inventory.counts.counted": "Counted",
4515
+ "inventory.counts.divergent": "Divergences",
4516
+ "inventory.counts.varianceValue": "Variance",
4517
+ "inventory.counts.saveFailed": "Could not save this line",
4518
+ "inventory.counts.close": "Close the count",
4519
+ "inventory.counts.closeTitle": "Close the count?",
4520
+ "inventory.counts.closeDescription": "{{divergent}} line(s) differ from the system and will get an adjustment. {{uncounted}} line(s) were never counted and are left exactly as they are.",
4521
+ "inventory.counts.closeConfirm": "Close and adjust",
4522
+ "inventory.counts.closed": "Count closed \u2014 {{count}} adjustment(s) recorded",
4523
+ "inventory.counts.alreadyClosed": "This count was already closed",
4524
+ "inventory.counts.closeFailed": "Could not close the count",
4525
+ "inventory.counts.cancelCount": "Cancel count",
4526
+ "inventory.counts.cancelTitle": "Cancel this count?",
4527
+ "inventory.counts.cancelDescription": "The count is filed away and nothing is adjusted. What you typed stays on record.",
4528
+ "inventory.counts.cancelConfirm": "Cancel the count",
4529
+ "inventory.counts.cancelled": "Count cancelled",
4530
+ "inventory.counts.cancelFailed": "Could not cancel the count",
4531
+ "inventory.counts.adjustmentReason": "Stock count {{reference}}"
2487
4532
  };
2488
4533
 
2489
4534
  // src/locales/pt-BR.ts
@@ -2503,6 +4548,23 @@ var ptBR = {
2503
4548
  "inventory.dashboard.totalProducts": "Total de Produtos",
2504
4549
  "inventory.dashboard.totalValue": "Valor total do estoque",
2505
4550
  "inventory.history.noMovements": "Nenhuma movimenta\xE7\xE3o registrada",
4551
+ "inventory.history.columnDate": "Data",
4552
+ "inventory.history.columnProduct": "Produto",
4553
+ "inventory.history.columnType": "Tipo",
4554
+ "inventory.history.columnLocation": "Local",
4555
+ "inventory.history.columnQty": "Qtd",
4556
+ "inventory.history.columnTotal": "Total",
4557
+ "inventory.history.newEntry": "Nova entrada",
4558
+ "inventory.history.newExit": "Sa\xEDda",
4559
+ "inventory.nav.recipes": "Receitas",
4560
+ "registry.suppliers": "Fornecedores",
4561
+ "registry.suppliers.description": "De quem voc\xEA compra",
4562
+ "registry.product-categories": "Categorias",
4563
+ "registry.product-categories.description": "Como o cat\xE1logo \xE9 agrupado",
4564
+ "registry.stock-locations": "Locais de estoque",
4565
+ "registry.stock-locations.description": "Onde o estoque fica de fato \u2014 c\xE2mara fria, estoque seco, freezer",
4566
+ "registry.measurement-units": "Unidades de medida",
4567
+ "registry.measurement-units.description": "Como cada item \xE9 contado",
2506
4568
  "inventory.history.searchPlaceholder": "Buscar movimenta\xE7\xF5es...",
2507
4569
  "inventory.history.subtitle": "Todas as movimenta\xE7\xF5es de estoque",
2508
4570
  "inventory.history.title": "Hist\xF3rico de Estoque",
@@ -2586,6 +4648,21 @@ var ptBR = {
2586
4648
  "inventory.stock.transferLabel": "Transfer\xEAncia",
2587
4649
  "inventory.stock.unitCost": "Custo Unit\xE1rio",
2588
4650
  "inventory.stock.unitCostLabel": "Custo Unit\xE1rio",
4651
+ "inventory.stock.searchSupplier": "Buscar fornecedor...",
4652
+ "inventory.stock.noSuppliers": "Nenhum fornecedor cadastrado",
4653
+ "inventory.productDetail.stockTab": "Estoque",
4654
+ "inventory.productDetail.positionsTitle": "Estoque por local",
4655
+ "inventory.productDetail.onHand": "Em estoque",
4656
+ "inventory.productDetail.noPositions": "Sem estoque em nenhum local",
4657
+ "inventory.productDetail.historyTitle": "Movimenta\xE7\xE3o",
4658
+ "inventory.productDetail.undoLast": "Desfazer \xFAltimo movimento",
4659
+ "inventory.productDetail.undoTitle": "Desfazer o \xFAltimo movimento?",
4660
+ "inventory.productDetail.undoDescription": "{{type}} de {{quantity}} em {{date}} sai do hist\xF3rico, e o estoque que ele moveu volta.",
4661
+ "inventory.productDetail.undoConfirm": "Desfazer",
4662
+ "inventory.productDetail.undone": "Movimento desfeito",
4663
+ "inventory.productDetail.undoFailed": "N\xE3o foi poss\xEDvel desfazer o movimento",
4664
+ "inventory.productDetail.notFound": "Produto n\xE3o encontrado",
4665
+ "inventory.productDetail.backToList": "Voltar para produtos",
2589
4666
  "inventory.onboarding.welcome": "Bem-vindo ao Estoque",
2590
4667
  "inventory.onboarding.description": "Acompanhe produtos, gerencie n\xEDveis de estoque e monitore movimenta\xE7\xF5es do seu neg\xF3cio.",
2591
4668
  "inventory.onboarding.skip": "Pular configura\xE7\xE3o",
@@ -2658,6 +4735,31 @@ var ptBR = {
2658
4735
  "inventory.productForm.maxQuantity": "Quantidade M\xE1xima",
2659
4736
  "inventory.productForm.maxQuantityHint": "Capacidade m\xE1xima para este produto",
2660
4737
  "inventory.productForm.optional": "Opcional",
4738
+ "inventory.productForm.organization": "Organiza\xE7\xE3o",
4739
+ "inventory.productForm.organizationDesc": "Categoria, unidade, fornecedor e local padr\xE3o de estoque",
4740
+ "inventory.productForm.category": "Categoria",
4741
+ "inventory.productForm.measurementUnit": "Unidade de medida",
4742
+ "inventory.productForm.measurementUnitHint": "Como este item \xE9 contado no estoque",
4743
+ "inventory.productForm.supplier": "Fornecedor padr\xE3o",
4744
+ "inventory.productForm.defaultLocation": "Local padr\xE3o",
4745
+ "inventory.movementType.entry": "Entrada",
4746
+ "inventory.movementType.exit": "Sa\xEDda",
4747
+ "inventory.movementType.adjustment": "Ajuste",
4748
+ "inventory.movementType.transfer": "Transfer\xEAncia",
4749
+ "inventory.movementType.loss": "Perda",
4750
+ "inventory.recipeForm.group": "Etapa",
4751
+ "inventory.recipeForm.ungrouped": "Sem etapa",
4752
+ "inventory.productList.bulkActivate": "Ativar",
4753
+ "inventory.productList.bulkActivated": "{{count}} produto(s) ativado(s)",
4754
+ "inventory.productList.bulkDeactivate": "Desativar",
4755
+ "inventory.productList.bulkDeactivated": "{{count}} produto(s) desativado(s)",
4756
+ "inventory.productList.bulkFailed": "{{count}} produto(s) n\xE3o puderam ser atualizados",
4757
+ "inventory.productList.bulkNothingToDo": "Os produtos selecionados j\xE1 est\xE3o nesse estado.",
4758
+ "inventory.productList.filterActive": "Ativos",
4759
+ "inventory.productList.filterAllStatuses": "Ativos e inativos",
4760
+ "inventory.productList.filterInactive": "Inativos",
4761
+ "inventory.productList.filterStatus": "Situa\xE7\xE3o",
4762
+ "inventory.productList.inactive": "Inativo",
2661
4763
  "inventory.productList.title": "Produtos",
2662
4764
  "inventory.productList.subtitle": "{{count}} produtos",
2663
4765
  "inventory.productList.product": "Produto",
@@ -2666,6 +4768,14 @@ var ptBR = {
2666
4768
  "inventory.productList.cost": "Custo",
2667
4769
  "inventory.productList.value": "Valor",
2668
4770
  "inventory.productList.searchPlaceholder": "Buscar produtos...",
4771
+ "inventory.productList.aiHint": "Descreva o produto e eu cadastro.",
4772
+ "inventory.productList.askPlaceholder": "Descreva o produto\u2026",
4773
+ "inventory.productList.askPersona": "Cadastro de produtos",
4774
+ "inventory.productList.askPitch": "Diga o produto que voc\xEA quer cadastrar.",
4775
+ "inventory.productList.askDescription": "Nome, pre\xE7o, custo, SKU, tipo \u2014 diga o que souber, do jeito que falar, e eu preencho o resto com o que faz sentido. Voc\xEA confere antes de salvar.",
4776
+ "inventory.productList.askSuggestion.0": "Camiseta preta P/M/G, R$ 79, custo R$ 28",
4777
+ "inventory.productList.askSuggestion.1": "Cadastre 5 produtos de exemplo para eu testar a loja",
4778
+ "inventory.productList.askSuggestion.2": "Um combo com os dois itens mais vendidos",
2669
4779
  "inventory.productList.newProduct": "Novo Produto",
2670
4780
  "inventory.productList.empty": "Nenhum produto ainda",
2671
4781
  "inventory.productList.createFirst": "Crie seu primeiro produto",
@@ -2726,7 +4836,96 @@ var ptBR = {
2726
4836
  "inventory.movements.title": "Movimenta\xE7\xF5es",
2727
4837
  "inventory.movements.in": "Entrada",
2728
4838
  "inventory.movements.out": "Sa\xEDda",
2729
- "inventory.settings.title": "Configura\xE7\xF5es de Estoque"
4839
+ "inventory.settings.title": "Configura\xE7\xF5es de Estoque",
4840
+ "inventory.recipeDetail.cost": "Custo",
4841
+ "inventory.recipeDetail.unitCost": "Custo unit\xE1rio",
4842
+ "inventory.recipeDetail.totalCost": "Custo da ficha",
4843
+ "inventory.recipeDetail.costPerYield": "Custo por unidade produzida",
4844
+ "inventory.recipeDetail.salePrice": "Pre\xE7o de venda",
4845
+ "inventory.recipeDetail.cmv": "CMV",
4846
+ "inventory.recipeDetail.cmvUnknown": "Sem pre\xE7o de venda",
4847
+ "inventory.recipeDetail.cmvHigh": "Acima de {{threshold}}% \u2014 este prato est\xE1 comendo a margem.",
4848
+ "inventory.recipeGaps.title": "Falta ligar ao seu cat\xE1logo",
4849
+ "inventory.recipeGaps.description": "A assistente sugeriu estes itens mas n\xE3o achou equivalente. Escolha o insumo certo ou cadastre.",
4850
+ "inventory.recipeGaps.produces": "Produz:",
4851
+ "inventory.recipeGaps.unnamed": "Ingrediente sem nome",
4852
+ "inventory.recipeGaps.linkFailed": "N\xE3o foi poss\xEDvel ligar este item",
4853
+ "inventory.recipeGaps.createFailed": "N\xE3o foi poss\xEDvel cadastrar o produto",
4854
+ "inventory.recipes.entity": "Ficha t\xE9cnica",
4855
+ "inventory.recipes.colProduct": "Produto",
4856
+ "inventory.recipes.colIngredients": "Insumos",
4857
+ "inventory.recipes.colYield": "Rendimento",
4858
+ "inventory.recipes.colPrep": "Preparo",
4859
+ "inventory.recipes.colCost": "Custo",
4860
+ "inventory.recipes.colSale": "Venda",
4861
+ "inventory.recipes.colCmv": "CMV",
4862
+ "inventory.recipes.noSheet": "Sem ficha t\xE9cnica",
4863
+ "inventory.recipes.searchPlaceholder": "Buscar item do card\xE1pio...",
4864
+ "inventory.recipes.newByHand": "Preencher \xE0 m\xE3o",
4865
+ "inventory.recipes.newByHandHint": "Abrir o formul\xE1rio e lan\xE7ar os insumos voc\xEA mesmo",
4866
+ "inventory.recipes.aiHint": "Descreva o prato e receba a ficha montada com os seus insumos",
4867
+ "inventory.recipes.askPlaceholder": "ex. ficha t\xE9cnica do cupim defumado com queijo, 400g, serve 3",
4868
+ "inventory.recipes.askPersona": "Fichas t\xE9cnicas",
4869
+ "inventory.recipes.askPitch": "Vamos custear o seu card\xE1pio",
4870
+ "inventory.recipes.askDescription": "Descreva o prato que eu monto a ficha com os insumos que voc\xEA j\xE1 tem, com custo e CMV.",
4871
+ "inventory.recipes.askSuggestion.0": "Monte a ficha t\xE9cnica do cupim defumado com queijo",
4872
+ "inventory.recipes.askSuggestion.1": "Quais itens do card\xE1pio ainda n\xE3o t\xEAm ficha t\xE9cnica?",
4873
+ "inventory.recipes.askSuggestion.2": "Quais pratos est\xE3o acima de 35% de CMV?",
4874
+ "inventory.nav.counts": "Contagens",
4875
+ "inventory.counts.title": "Contagens de estoque",
4876
+ "inventory.counts.entity": "Contagem",
4877
+ "inventory.counts.colReference": "Contagem",
4878
+ "inventory.counts.colOpened": "Aberta em",
4879
+ "inventory.counts.colStatus": "Situa\xE7\xE3o",
4880
+ "inventory.counts.colProgress": "Contados",
4881
+ "inventory.counts.colDivergent": "Diverg\xEAncias",
4882
+ "inventory.counts.status.open": "Aberta",
4883
+ "inventory.counts.status.counting": "Contando",
4884
+ "inventory.counts.status.closed": "Fechada",
4885
+ "inventory.counts.status.cancelled": "Cancelada",
4886
+ "inventory.counts.progress": "{{counted}} de {{total}}",
4887
+ "inventory.counts.newCount": "Nova contagem",
4888
+ "inventory.counts.searchPlaceholder": "Buscar contagem...",
4889
+ "inventory.counts.empty": "Nenhuma contagem ainda",
4890
+ "inventory.counts.formTitle": "Abrir uma contagem",
4891
+ "inventory.counts.formSubtitle": "O que o sistema acredita \xE9 congelado na hora em que voc\xEA abre \u2014 a varia\xE7\xE3o fala da prateleira, n\xE3o do rel\xF3gio.",
4892
+ "inventory.counts.location": "Local de estoque",
4893
+ "inventory.counts.pickLocation": "Escolha um local",
4894
+ "inventory.counts.category": "Categoria",
4895
+ "inventory.counts.allCategories": "Todas as categorias",
4896
+ "inventory.counts.reference": "Nome",
4897
+ "inventory.counts.referencePlaceholder": "ex. C\xE2mara fria, fim de agosto",
4898
+ "inventory.counts.blind": "Contagem cega",
4899
+ "inventory.counts.blindHint": "Esconde a quantidade do sistema enquanto voc\xEA conta, para escrever o que est\xE1 na prateleira em vez de confirmar o que est\xE1 na tela.",
4900
+ "inventory.counts.blindLine": "Cega \u2014 quantidade do sistema oculta",
4901
+ "inventory.counts.notes": "Observa\xE7\xF5es",
4902
+ "inventory.counts.notesPlaceholder": "O que a pr\xF3xima pessoa precisa saber",
4903
+ "inventory.counts.open": "Abrir a contagem",
4904
+ "inventory.counts.opening": "Abrindo...",
4905
+ "inventory.counts.openFailed": "N\xE3o foi poss\xEDvel abrir a contagem",
4906
+ "inventory.counts.cancel": "Cancelar",
4907
+ "inventory.counts.notFound": "Contagem n\xE3o encontrada",
4908
+ "inventory.counts.noItems": "Esta contagem n\xE3o tem produtos",
4909
+ "inventory.counts.systemQty": "Sistema:",
4910
+ "inventory.counts.notCounted": "N\xE3o contado",
4911
+ "inventory.counts.counted": "Contados",
4912
+ "inventory.counts.divergent": "Diverg\xEAncias",
4913
+ "inventory.counts.varianceValue": "Varia\xE7\xE3o",
4914
+ "inventory.counts.saveFailed": "N\xE3o foi poss\xEDvel salvar esta linha",
4915
+ "inventory.counts.close": "Fechar a contagem",
4916
+ "inventory.counts.closeTitle": "Fechar a contagem?",
4917
+ "inventory.counts.closeDescription": "{{divergent}} linha(s) divergem do sistema e v\xE3o receber um ajuste. {{uncounted}} linha(s) ningu\xE9m contou e ficam exatamente como est\xE3o.",
4918
+ "inventory.counts.closeConfirm": "Fechar e ajustar",
4919
+ "inventory.counts.closed": "Contagem fechada \u2014 {{count}} ajuste(s) lan\xE7ado(s)",
4920
+ "inventory.counts.alreadyClosed": "Esta contagem j\xE1 estava fechada",
4921
+ "inventory.counts.closeFailed": "N\xE3o foi poss\xEDvel fechar a contagem",
4922
+ "inventory.counts.cancelCount": "Cancelar contagem",
4923
+ "inventory.counts.cancelTitle": "Cancelar esta contagem?",
4924
+ "inventory.counts.cancelDescription": "A contagem \xE9 arquivada e nada \xE9 ajustado. O que voc\xEA digitou fica registrado.",
4925
+ "inventory.counts.cancelConfirm": "Cancelar a contagem",
4926
+ "inventory.counts.cancelled": "Contagem cancelada",
4927
+ "inventory.counts.cancelFailed": "N\xE3o foi poss\xEDvel cancelar a contagem",
4928
+ "inventory.counts.adjustmentReason": "Contagem de estoque {{reference}}"
2730
4929
  };
2731
4930
 
2732
4931
  // src/locales/index.ts
@@ -2781,9 +4980,10 @@ function createInventoryPlugin(options) {
2781
4980
  );
2782
4981
  const store = createInventoryStore(provider);
2783
4982
  const dashboardWidgets = createInventoryDashboardWidgets({ config, provider, store });
2784
- const PageComponent = () => React2.createElement(InventoryPage, { config, provider, store, registries: inventoryRegistries });
4983
+ const PageComponent = () => React3.createElement(InventoryPage, { config, provider, store, registries: inventoryRegistries });
2785
4984
  return {
2786
4985
  id: "inventory",
4986
+ defaultAgentRole: "operations",
2787
4987
  name: config.labels.pageTitle,
2788
4988
  icon: "Package",
2789
4989
  version: "1.0.0",
@@ -2873,7 +5073,18 @@ function createInventoryPlugin(options) {
2873
5073
  { label: "What are my most used products?" }
2874
5074
  ],
2875
5075
  permission: { feature: "inventory", action: "read" }
2876
- }
5076
+ },
5077
+ // `role: 'finance'` is on the tool itself — the plugin defaults to
5078
+ // 'operations' and cost per dish belongs to the finance colleague.
5079
+ ...config.modules.recipes ? [upsertRecipeTool] : []
5080
+ ],
5081
+ declaredRpcs: [
5082
+ ...config.modules.recipes ? [{
5083
+ name: "agent_inventory_upsert_recipe",
5084
+ kind: "write",
5085
+ description: "Guarded recipe upsert: agent_guard (role\u2192plan\u2192recipes cap), ingredient names resolved to products.id inside SQL, unmatched ones returned and stored for the screen to recover, audited.",
5086
+ audits: true
5087
+ }] : []
2877
5088
  ],
2878
5089
  registries: inventoryRegistries,
2879
5090
  settings: [
@@ -2881,8 +5092,19 @@ function createInventoryPlugin(options) {
2881
5092
  id: "inventory",
2882
5093
  label: "Inventory",
2883
5094
  icon: "Package",
5095
+ // PluginSettingsPanel, not the toggles component directly. Declaring
5096
+ // `registries` does nothing for settings on its own — the panel is the
5097
+ // only thing that turns them into screens, which is why Stock Locations,
5098
+ // Categories, Units and Suppliers all existed in code and appeared
5099
+ // nowhere. financial and crm already render through it.
2884
5100
  component: (() => {
2885
- const Tab = () => React2.createElement(InventoryGeneralSettings);
5101
+ const Tab = () => React3.createElement(PluginSettingsPanel, {
5102
+ title: config.labels.pageTitle,
5103
+ generalSettings: React3.createElement(InventoryGeneralSettings),
5104
+ registries: inventoryRegistries,
5105
+ hostPluginId: "inventory",
5106
+ routeBase: "/settings/inventory"
5107
+ });
2886
5108
  Tab.displayName = "InventorySettingsTab";
2887
5109
  return Tab;
2888
5110
  })(),
@@ -2894,6 +5116,6 @@ function createInventoryPlugin(options) {
2894
5116
  };
2895
5117
  }
2896
5118
 
2897
- export { createInventoryPlugin };
5119
+ export { LEGACY_PT_MOVEMENT_TYPE, MOVEMENT_TYPES, MOVEMENT_TYPE_VALUES, createInventoryPlugin, movementTypeSpec, normalizeMovementType };
2898
5120
  //# sourceMappingURL=index.js.map
2899
5121
  //# sourceMappingURL=index.js.map