@fayz-ai/plugin-inventory 0.15.4 → 0.16.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.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import React4, { useMemo, useState, useEffect, useCallback, useRef } from 'react';
2
- import { toast, defineKpiWidget, defineCustomWidget, PageTransition, ComingSoon, ModulePage, Button, FilterPanel, FilterPanelField, RequiredFilter, FilterMenuRow, SubpageHeader, SearchSelect, cn, DatePicker, InlineLink, ListView, Modal, ModalContent, Card, Input, DataTable, SegmentedControl, ConfirmDialog, Badge, DraftCommitBar, Switch, TooltipProvider, DashboardCanvas, Thumbnail, Skeleton, CurrencyInput, ModalHeader, ModalTitle, ModalBody, Checkbox, ModalFooter, AiActionButton, ModalDescription, Tooltip, TooltipTrigger, TooltipContent, CardContent, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, StockEntryForm, useSaveBar, Dropdown, DropdownTrigger, DropdownContent, DropdownItem, DropdownSeparator, openPrintWindow, printDocument, createXlsxReport, KpiCard, CardHeader, CardTitle } from '@fayz-ai/ui';
3
- import { createPluginContext, UNIT_DETAIL_ZONES, PluginSettingsPanel, dedup, RelatedListTab, useTenantPluginSettings, useModuleNavigation, usePluginOnboardingGate, PluginOnboardingGateSkeleton, createViewRouter, RegistryCrudView, ModuleActionBar, SettingsGroup, ToggleRow, usePermissionOptional, CrudListView, useLimitGuard, invalidateRelationOptions, CrudFormSkeleton, CrudFormPage, CrudDetailPage, formatCurrency, resolveDocumentOpenerFor, useRememberedList, usePersistedState, useScrollMemory, useAgentSurface, PermissionGate, invalidateLimit, RecordPickerModal } from '@fayz-ai/admin';
1
+ import React6, { useMemo, useState, useEffect, useCallback, useRef } from 'react';
2
+ import { toast, defineKpiWidget, defineCustomWidget, PageTransition, ComingSoon, ModulePage, Button, FilterPanel, FilterPanelField, RequiredFilter, FilterMenuRow, SubpageHeader, SearchSelect, cn, DatePicker, InlineLink, ListView, Modal, ModalContent, Card, Input, DataTable, SegmentedControl, ConfirmDialog, Badge, DraftCommitBar, Switch, TooltipProvider, PageHeaderActions, DashboardCanvas, Thumbnail, Skeleton, CurrencyInput, ModalHeader, ModalTitle, ModalBody, Checkbox, ModalFooter, AiActionButton, ModalDescription, Tooltip, TooltipTrigger, TooltipContent, CardContent, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, StockEntryForm, useSaveBar, Dropdown, DropdownTrigger, DropdownContent, DropdownItem, DropdownSeparator, openPrintWindow, printDocument, createXlsxReport, KpiCard, CardHeader, CardTitle } from '@fayz-ai/ui';
3
+ import { createPluginContext, UNIT_DETAIL_ZONES, PluginSettingsPanel, dedup, RelatedListTab, useModuleNavigation, usePluginOnboardingGate, PluginOnboardingGateSkeleton, createViewRouter, RegistryCrudView, ModuleActionBar, useTenantPluginSettings, SettingsGroup, ToggleRow, useOrganizationStore, usePermissionOptional, CrudListView, useLimitGuard, invalidateRelationOptions, CrudFormSkeleton, CrudFormPage, CrudDetailPage, formatCurrency, resolveDocumentOpenerFor, useRememberedList, usePersistedState, useScrollMemory, useAgentSurface, PageActions, useOfferedPlugins, invalidateLimit, RecordPickerModal } from '@fayz-ai/admin';
4
4
  import { recordRailFields, RECORD_GROUP, activeFacet, createSafeDataProvider, registerTranslations, tl, countByTenant, useTranslation, getActiveUnitId, createPendingCommand, emitDataChanged, getSupabaseClientOptional, getActiveTenantId, errorMessage } from '@fayz-ai/core';
5
5
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
6
6
  import { Trash2, ArrowRightLeft, RefreshCw, ArrowUpRight, ArrowDownRight, Package, Ruler, MapPin, Loader2, Check, ChevronRight, CircleSlash, CircleCheck, Boxes, Layers, CircleDashed, Search, ListFilter, CalendarRange, PackageCheck, ClipboardCheck, AlertTriangle, Plus, ArrowUp, ArrowDown, Sparkles, X, Upload, Building2, Utensils, Clock, Printer, Pencil, Star, Undo2, MoreVertical, PackageX } from 'lucide-react';
@@ -491,6 +491,113 @@ function ProductBarcodesTab({ item }) {
491
491
  ] }) })
492
492
  );
493
493
  }
494
+ function ListSkeleton() {
495
+ return /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsx(CardContent, { className: "p-0", children: /* @__PURE__ */ jsx("ul", { className: "divide-y", "aria-busy": "true", children: [28, 16, 20].map((width, i) => /* @__PURE__ */ jsxs("li", { className: "flex items-center gap-3 px-4 py-3", children: [
496
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-4", style: { width: `${width}%` } }),
497
+ /* @__PURE__ */ jsx("span", { className: "flex-1" }),
498
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-24" }),
499
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-5 w-9 rounded-full" })
500
+ ] }, i)) }) }) });
501
+ }
502
+ function ProductVariantsTab({ item }) {
503
+ const t2 = useTranslation();
504
+ const provider = useInventoryProvider();
505
+ const productId = item?.id;
506
+ const [rows, setRows] = useState(null);
507
+ const [name, setName] = useState("");
508
+ const [sku, setSku] = useState("");
509
+ const [saving, setSaving] = useState(false);
510
+ const load = useCallback(async () => {
511
+ if (!productId || !provider.getProductVariants) {
512
+ setRows([]);
513
+ return;
514
+ }
515
+ try {
516
+ setRows(await provider.getProductVariants(productId));
517
+ } catch (error) {
518
+ setRows([]);
519
+ toast.error(t2("inventory.variants.loadFailed"), { description: String(error?.message ?? "") });
520
+ }
521
+ }, [productId, provider, t2]);
522
+ useEffect(() => {
523
+ void load();
524
+ }, [load]);
525
+ if (!provider.addProductVariant) {
526
+ return /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsx(CardContent, { className: "p-8 text-center", children: /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: t2("inventory.variants.unavailable") }) }) });
527
+ }
528
+ async function add() {
529
+ const value = name.trim();
530
+ if (!value || !productId) return;
531
+ if ((rows ?? []).some((r) => r.name.trim().toLowerCase() === value.toLowerCase())) {
532
+ toast.error(t2("inventory.variants.duplicate"));
533
+ return;
534
+ }
535
+ setSaving(true);
536
+ try {
537
+ await provider.addProductVariant(productId, { name: value, sku });
538
+ setName("");
539
+ setSku("");
540
+ await load();
541
+ } catch (error) {
542
+ toast.error(t2("inventory.variants.saveFailed"), { description: String(error?.message ?? "") });
543
+ } finally {
544
+ setSaving(false);
545
+ }
546
+ }
547
+ async function toggle(row, active) {
548
+ setRows((prev) => (prev ?? []).map((r) => r.id === row.id ? { ...r, isActive: active } : r));
549
+ try {
550
+ await provider.setProductVariantActive?.(row.id, active);
551
+ } catch (error) {
552
+ toast.error(t2("inventory.variants.saveFailed"), { description: String(error?.message ?? "") });
553
+ await load();
554
+ }
555
+ }
556
+ const sized = (rows ?? []).some((r) => !r.isDefault);
557
+ const shown = (rows ?? []).filter((r) => !r.isDefault || !sized || r.onHand !== 0);
558
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-5", children: [
559
+ /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsx(CardContent, { className: "pt-4", children: /* @__PURE__ */ jsxs("div", { className: "fz-fields fz-fields-3", children: [
560
+ /* @__PURE__ */ jsxs("div", { children: [
561
+ /* @__PURE__ */ jsx("label", { className: "mb-1 block text-xs font-medium", children: t2("inventory.variants.name") }),
562
+ /* @__PURE__ */ jsx(
563
+ Input,
564
+ {
565
+ value: name,
566
+ onChange: (e) => setName(e.target.value),
567
+ placeholder: t2("inventory.variants.namePlaceholder"),
568
+ onKeyDown: (e) => {
569
+ if (e.key === "Enter") {
570
+ e.preventDefault();
571
+ void add();
572
+ }
573
+ }
574
+ }
575
+ )
576
+ ] }),
577
+ /* @__PURE__ */ jsxs("div", { children: [
578
+ /* @__PURE__ */ jsx("label", { className: "mb-1 block text-xs font-medium", children: t2("inventory.variants.sku") }),
579
+ /* @__PURE__ */ jsx(Input, { value: sku, onChange: (e) => setSku(e.target.value), placeholder: t2("inventory.variants.skuPlaceholder") })
580
+ ] }),
581
+ /* @__PURE__ */ jsx("div", { className: "flex items-end justify-end", children: /* @__PURE__ */ jsxs(Button, { onClick: () => void add(), disabled: !name.trim() || saving, children: [
582
+ /* @__PURE__ */ jsx(Plus, { className: "mr-1.5 h-3.5 w-3.5" }),
583
+ t2("common.add")
584
+ ] }) })
585
+ ] }) }) }),
586
+ rows === null && /* @__PURE__ */ jsx(ListSkeleton, {}),
587
+ rows !== null && shown.length === 0 && /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsx(CardContent, { className: "p-8 text-center", children: /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: t2("inventory.variants.empty") }) }) }),
588
+ rows !== null && shown.length > 0 && /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsx(CardContent, { className: "p-0", children: /* @__PURE__ */ jsx("ul", { className: "divide-y", children: shown.map((row) => /* @__PURE__ */ jsxs("li", { className: "flex items-center gap-3 px-4 py-3", children: [
589
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
590
+ /* @__PURE__ */ jsxs("p", { className: `flex items-center gap-2 text-sm font-medium ${row.isActive ? "" : "text-muted-foreground line-through"}`, children: [
591
+ /* @__PURE__ */ jsx("span", { className: "truncate", children: row.name }),
592
+ row.isDefault && row.name.trim() !== t2("inventory.variants.default") && /* @__PURE__ */ jsx(Badge, { variant: "secondary", className: "shrink-0", children: t2("inventory.variants.default") })
593
+ ] }),
594
+ row.sku && /* @__PURE__ */ jsx("p", { className: "truncate font-mono text-xs text-muted-foreground", children: row.sku })
595
+ ] }),
596
+ /* @__PURE__ */ jsx("span", { className: "shrink-0 text-sm tabular-nums text-muted-foreground", children: t2("inventory.variants.onHand", { count: row.onHand.toLocaleString("pt-BR") }) }),
597
+ /* @__PURE__ */ jsx(Switch, { checked: row.isActive, onCheckedChange: (v) => void toggle(row, v), "aria-label": t2("common.active") })
598
+ ] }, row.id)) }) }) })
599
+ ] });
600
+ }
494
601
 
495
602
  // src/views/productFichas.ts
496
603
  var yes = (key3) => (vals) => vals[key3] === true;
@@ -560,7 +667,7 @@ var ASSET_STATUS_OPTIONS = [
560
667
  { value: "disposed", label: "Baixado" },
561
668
  { value: "lost", label: "Extraviado" }
562
669
  ];
563
- function classificationFields(t2, currency) {
670
+ function classificationFields(t2, currency, hasRecipes = true) {
564
671
  const flag = (key3, label, hint) => ({
565
672
  key: key3,
566
673
  label,
@@ -573,9 +680,9 @@ function classificationFields(t2, currency) {
573
680
  return [
574
681
  flag("isIngredient", t2("inventory.productForm.flagIngredient"), t2("inventory.productForm.flagIngredientHint")),
575
682
  flag("isForSale", t2("inventory.productForm.flagForSale"), t2("inventory.productForm.flagForSaleHint")),
576
- flag("isIntermediate", t2("inventory.productForm.flagIntermediate"), t2("inventory.productForm.flagIntermediateHint")),
683
+ ...hasRecipes ? [flag("isIntermediate", t2("inventory.productForm.flagIntermediate"), t2("inventory.productForm.flagIntermediateHint"))] : [],
577
684
  flag("isAsset", t2("inventory.productForm.flagAsset"), t2("inventory.productForm.flagAssetHint")),
578
- flag("isProduced", t2("inventory.productForm.flagProduced"), t2("inventory.productForm.flagProducedHint")),
685
+ ...hasRecipes ? [flag("isProduced", t2("inventory.productForm.flagProduced"), t2("inventory.productForm.flagProducedHint"))] : [],
579
686
  flag("isResale", t2("inventory.productForm.flagResale"), t2("inventory.productForm.flagResaleHint")),
580
687
  flag("trackByUnitLabel", t2("inventory.productForm.flagTracked"), t2("inventory.productForm.flagTrackedHint")),
581
688
  {
@@ -985,6 +1092,7 @@ function toFichas(values) {
985
1092
  for (const name of Object.keys(FICHA_KEYS)) {
986
1093
  if (!enabled[name]) continue;
987
1094
  const { prefix, keys } = FICHA_KEYS[name];
1095
+ if (!keys.some((key3) => flatKey(prefix, key3) in values)) continue;
988
1096
  const ficha = {};
989
1097
  for (const key3 of keys) {
990
1098
  const raw = values[flatKey(prefix, key3)];
@@ -1068,7 +1176,7 @@ function productImage(provider) {
1068
1176
  draftField: "_imageFile"
1069
1177
  };
1070
1178
  }
1071
- function buildProductEntity(t2, productTypes, currency, onStockChanged, provider, hasFiscal = true) {
1179
+ function buildProductEntity(t2, productTypes, currency, onStockChanged, provider, hasFiscal = true, hasMenu = true, hasRecipes = true) {
1072
1180
  const typeName = (pt) => pt.labelKey ? t2(pt.labelKey) : pt.label;
1073
1181
  const typeOptions = productTypes.map((pt) => ({
1074
1182
  label: typeName(pt),
@@ -1297,7 +1405,12 @@ function buildProductEntity(t2, productTypes, currency, onStockChanged, provider
1297
1405
  label: t2("inventory.productForm.category"),
1298
1406
  type: "relation",
1299
1407
  group: "organization",
1300
- relation: { table: T.productCategories, labelField: "name", filter: { kind: PRODUCT_CATEGORY_KIND } },
1408
+ relation: {
1409
+ table: T.productCategories,
1410
+ labelField: "name",
1411
+ filter: { kind: PRODUCT_CATEGORY_KIND },
1412
+ create: { permission: { feature: "inventory", action: "edit" } }
1413
+ },
1301
1414
  placeholder: t2("inventory.productForm.optional"),
1302
1415
  showInTable: false
1303
1416
  },
@@ -1336,10 +1449,10 @@ function buildProductEntity(t2, productTypes, currency, onStockChanged, provider
1336
1449
  // As fichas: cada uma nasce de um interruptor da Classificação e some
1337
1450
  // junto com ele. A ordem aqui é a ordem das abas do V1.
1338
1451
  ...packagingFields(t2),
1339
- ...classificationFields(t2, currency),
1452
+ ...classificationFields(t2, currency, hasRecipes),
1340
1453
  ...ingredientFields(t2),
1341
- ...saleFields(t2, currency),
1342
- ...intermediateFields(t2),
1454
+ ...hasMenu ? saleFields(t2, currency) : [],
1455
+ ...hasRecipes ? intermediateFields(t2) : [],
1343
1456
  ...assetFields(t2, currency),
1344
1457
  ...hasFiscal ? taxFields(t2) : []
1345
1458
  ];
@@ -1348,6 +1461,8 @@ function buildProductEntity(t2, productTypes, currency, onStockChanged, provider
1348
1461
  namePlural: t2("inventory.nav.products"),
1349
1462
  icon: "Package",
1350
1463
  displayField: "name",
1464
+ // General data in the main column, situation (unit, tags, active) beside it.
1465
+ formColumns: 2,
1351
1466
  // MOSTRAR e TROCAR são coisas diferentes. `image` só existe quando há
1352
1467
  // provider, porque é ele que sabe enviar o arquivo — mas sem `imageField` a
1353
1468
  // página do produto ficava sem foto mesmo com a foto na linha, enquanto a
@@ -1374,10 +1489,9 @@ function buildProductEntity(t2, productTypes, currency, onStockChanged, provider
1374
1489
  // Fiscal · Intermediário · Patrimônio. A aba só aparece com o
1375
1490
  // interruptor dela ligado.
1376
1491
  ficha("ingredient"),
1377
- ficha("sale"),
1378
- ficha("saleChannels"),
1492
+ ...hasMenu ? [ficha("sale"), ficha("saleChannels")] : [],
1379
1493
  ...hasFiscal ? [ficha("tax")] : [],
1380
- ficha("intermediate"),
1494
+ ...hasRecipes ? [ficha("intermediate")] : [],
1381
1495
  ficha("asset"),
1382
1496
  { ...RECORD_GROUP }
1383
1497
  ];
@@ -1398,6 +1512,12 @@ function buildProductEntity(t2, productTypes, currency, onStockChanged, provider
1398
1512
  icon: "ScanLine",
1399
1513
  component: ProductBarcodesTab
1400
1514
  },
1515
+ {
1516
+ id: "variants",
1517
+ label: t2("inventory.variants.tab"),
1518
+ icon: "Layers",
1519
+ component: ProductVariantsTab
1520
+ },
1401
1521
  {
1402
1522
  id: "stock",
1403
1523
  label: t2("inventory.productDetail.stockTab"),
@@ -1633,6 +1753,87 @@ function ProductListView({ onNew, onOpen }) {
1633
1753
  )
1634
1754
  ] });
1635
1755
  }
1756
+ var INVENTORY_MODULE_DEFAULTS = {
1757
+ recipes: true,
1758
+ requisitions: true,
1759
+ usageAudit: true,
1760
+ temperature: true,
1761
+ // Tabs another plugin lends to Inventory (invoice entry), switched off by id.
1762
+ hiddenViews: []
1763
+ };
1764
+ var NO_RECIPE_VERTICALS = /* @__PURE__ */ new Set(["beauty"]);
1765
+ function inventoryModuleDefaults(verticalId) {
1766
+ return NO_RECIPE_VERTICALS.has(verticalId ?? "") ? { ...INVENTORY_MODULE_DEFAULTS, recipes: false } : INVENTORY_MODULE_DEFAULTS;
1767
+ }
1768
+ function useInventoryHouseModules() {
1769
+ const verticalId = useOrganizationStore((state) => state.currentOrg?.verticalId);
1770
+ const defaults = React6.useMemo(() => inventoryModuleDefaults(verticalId), [verticalId]);
1771
+ return useTenantPluginSettings("inventory", defaults);
1772
+ }
1773
+ var INVENTORY_DEFAULTS = {
1774
+ lowStockAlerts: true,
1775
+ requireReason: true,
1776
+ autoDeduct: false,
1777
+ requireSku: false,
1778
+ allowNegative: false,
1779
+ lowStockEmail: false,
1780
+ expiryWarnings: true
1781
+ };
1782
+ function InventoryGeneralSettings({ config }) {
1783
+ const t2 = useTranslation();
1784
+ const s = useTenantPluginSettings("inventory", INVENTORY_DEFAULTS);
1785
+ const mods = useInventoryHouseModules();
1786
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
1787
+ /* @__PURE__ */ jsxs(SettingsGroup, { title: t2("inventory.settings.modules"), description: t2("inventory.settings.modulesDesc"), children: [
1788
+ config?.modules.recipes && /* @__PURE__ */ jsx(
1789
+ ToggleRow,
1790
+ {
1791
+ label: t2("inventory.settings.recipes"),
1792
+ description: t2("inventory.settings.recipesDesc"),
1793
+ checked: mods.get("recipes"),
1794
+ onChange: (v) => mods.set("recipes", v)
1795
+ }
1796
+ ),
1797
+ /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.requisitions"), description: t2("inventory.settings.requisitionsDesc"), checked: mods.get("requisitions"), onChange: (v) => mods.set("requisitions", v) }),
1798
+ /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.nav.usageAudit"), description: t2("inventory.settings.usageAuditDesc"), checked: mods.get("usageAudit"), onChange: (v) => mods.set("usageAudit", v) }),
1799
+ /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.temperature"), description: t2("inventory.settings.temperatureDesc"), checked: mods.get("temperature"), onChange: (v) => mods.set("temperature", v) }),
1800
+ (config?.extraViews ?? []).map((extra) => {
1801
+ const hidden = mods.get("hiddenViews");
1802
+ return /* @__PURE__ */ jsx(
1803
+ ToggleRow,
1804
+ {
1805
+ label: extra.label,
1806
+ checked: !hidden.includes(extra.id),
1807
+ onChange: (v) => mods.set("hiddenViews", v ? hidden.filter((id) => id !== extra.id) : [...hidden, extra.id])
1808
+ },
1809
+ extra.id
1810
+ );
1811
+ })
1812
+ ] }),
1813
+ /* @__PURE__ */ jsxs(SettingsGroup, { title: t2("inventory.settings.stockManagement"), description: t2("inventory.settings.stockManagementDesc"), children: [
1814
+ /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.lowStockAlerts"), description: t2("inventory.settings.lowStockAlertsDesc"), checked: s.get("lowStockAlerts"), onChange: (v) => s.set("lowStockAlerts", v) }),
1815
+ /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.requireReason"), description: t2("inventory.settings.requireReasonDesc"), checked: s.get("requireReason"), onChange: (v) => s.set("requireReason", v) }),
1816
+ /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.autoDeduct"), description: t2("inventory.settings.autoDeductDesc"), checked: s.get("autoDeduct"), onChange: (v) => s.set("autoDeduct", v) })
1817
+ ] }),
1818
+ /* @__PURE__ */ jsxs(SettingsGroup, { title: t2("inventory.settings.products"), description: t2("inventory.settings.productsDesc"), children: [
1819
+ /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.requireSku"), description: t2("inventory.settings.requireSkuDesc"), checked: s.get("requireSku"), onChange: (v) => s.set("requireSku", v) }),
1820
+ /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.allowNegative"), description: t2("inventory.settings.allowNegativeDesc"), checked: s.get("allowNegative"), onChange: (v) => s.set("allowNegative", v) })
1821
+ ] }),
1822
+ /* @__PURE__ */ jsxs(SettingsGroup, { title: t2("inventory.settings.notifications"), description: t2("inventory.settings.notificationsDesc"), children: [
1823
+ /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.lowStockEmail"), description: t2("inventory.settings.lowStockEmailDesc"), checked: s.get("lowStockEmail"), onChange: (v) => s.set("lowStockEmail", v) }),
1824
+ /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.expiryWarnings"), description: t2("inventory.settings.expiryWarningsDesc"), checked: s.get("expiryWarnings"), onChange: (v) => s.set("expiryWarnings", v) })
1825
+ ] })
1826
+ ] });
1827
+ }
1828
+
1829
+ // src/views/useHasMenu.ts
1830
+ function useHasMenu() {
1831
+ const offered = useOfferedPlugins();
1832
+ return offered.length === 0 || offered.includes("menu");
1833
+ }
1834
+ function useHasRecipes() {
1835
+ return useInventoryHouseModules().get("recipes");
1836
+ }
1636
1837
  function ProductCrudForm({ editId, onSaved }) {
1637
1838
  const t2 = useTranslation();
1638
1839
  const { productTypes, currency } = useInventoryConfig();
@@ -1640,10 +1841,10 @@ function ProductCrudForm({ editId, onSaved }) {
1640
1841
  const createProduct = useInventoryStore((s) => s.createProduct);
1641
1842
  const guardProducts = useLimitGuard("products");
1642
1843
  const isEdit = !!editId;
1643
- const [initialData, setInitialData] = React4.useState(isEdit ? null : {});
1644
- const [headerName, setHeaderName] = React4.useState("");
1645
- const [referenceDataReady, setReferenceDataReady] = React4.useState(false);
1646
- React4.useEffect(() => {
1844
+ const [initialData, setInitialData] = React6.useState(isEdit ? null : {});
1845
+ const [headerName, setHeaderName] = React6.useState("");
1846
+ const [referenceDataReady, setReferenceDataReady] = React6.useState(false);
1847
+ React6.useEffect(() => {
1647
1848
  let cancelled = false;
1648
1849
  (async () => {
1649
1850
  try {
@@ -1659,7 +1860,7 @@ function ProductCrudForm({ editId, onSaved }) {
1659
1860
  cancelled = true;
1660
1861
  };
1661
1862
  }, [provider]);
1662
- React4.useEffect(() => {
1863
+ React6.useEffect(() => {
1663
1864
  if (!editId) return;
1664
1865
  let cancelled = false;
1665
1866
  (async () => {
@@ -1672,9 +1873,11 @@ function ProductCrudForm({ editId, onSaved }) {
1672
1873
  cancelled = true;
1673
1874
  };
1674
1875
  }, [editId]);
1675
- const entity = React4.useMemo(
1676
- () => buildProductEntity(t2, productTypes, currency, void 0, provider),
1677
- [t2, productTypes, currency, provider]
1876
+ const hasMenu = useHasMenu();
1877
+ const hasRecipes = useHasRecipes();
1878
+ const entity = React6.useMemo(
1879
+ () => buildProductEntity(t2, productTypes, currency, void 0, provider, true, hasMenu, hasRecipes),
1880
+ [t2, productTypes, currency, provider, hasMenu, hasRecipes]
1678
1881
  );
1679
1882
  async function save(values) {
1680
1883
  const name = String(values.name ?? "").trim();
@@ -3356,7 +3559,7 @@ function DetailSkeleton() {
3356
3559
  }
3357
3560
  function ProductDetailView({ productId, initialTab, onBack, onEdit }) {
3358
3561
  const t2 = useTranslation();
3359
- const { currency, productTypes } = useInventoryConfig();
3562
+ const { currency, productTypes, productDetailTabs: contributedTabs } = useInventoryConfig();
3360
3563
  const provider = useInventoryProvider();
3361
3564
  const [product, setProduct] = useState(null);
3362
3565
  const [loading, setLoading] = useState(true);
@@ -3374,9 +3577,11 @@ function ProductDetailView({ productId, initialTab, onBack, onEdit }) {
3374
3577
  active = false;
3375
3578
  };
3376
3579
  }, [productId, provider, reloadKey]);
3580
+ const hasMenu = useHasMenu();
3581
+ const hasRecipes = useHasRecipes();
3377
3582
  const entity = useMemo(
3378
- () => buildProductEntity(t2, productTypes, currency, reload),
3379
- [t2, productTypes, currency, reload]
3583
+ () => buildProductEntity(t2, productTypes, currency, reload, void 0, true, hasMenu, hasRecipes),
3584
+ [t2, productTypes, currency, reload, hasMenu, hasRecipes]
3380
3585
  );
3381
3586
  const flat = useMemo(
3382
3587
  () => product ? flattenProduct(product) : null,
@@ -3389,15 +3594,16 @@ function ProductDetailView({ productId, initialTab, onBack, onEdit }) {
3389
3594
  const [photoOpen, setPhotoOpen] = useState(false);
3390
3595
  const entityForDetail = useMemo(() => {
3391
3596
  const extra = [];
3392
- if (flat?.isForSale && provider.getProductOptionGroups) {
3597
+ if (hasMenu && flat?.isForSale && provider.getProductOptionGroups) {
3393
3598
  extra.push({ id: "complements", label: t2("inventory.complements.tab"), icon: "ListPlus", component: ProductComplementsTab });
3394
3599
  }
3395
- if (flat?.isProduced || initialTab === "recipe") {
3600
+ if (hasRecipes && (flat?.isProduced || initialTab === "recipe")) {
3396
3601
  extra.push({ id: "recipe", label: t2("inventory.productRecipe.tab"), icon: "ChefHat", component: ProductRecipeTab, props: { onChanged: reload } });
3397
3602
  }
3398
3603
  const tabs = entity.detailTabs ?? [];
3399
3604
  const stockAt = tabs.findIndex((tab) => tab.id === "stock");
3400
- const detailTabs = stockAt < 0 ? [...tabs, ...extra] : [...tabs.slice(0, stockAt), ...extra, ...tabs.slice(stockAt)];
3605
+ const own = stockAt < 0 ? [...tabs, ...extra] : [...tabs.slice(0, stockAt), ...extra, ...tabs.slice(stockAt)];
3606
+ const detailTabs = [...own, ...contributedTabs];
3401
3607
  return {
3402
3608
  ...entity,
3403
3609
  detailTabs,
@@ -3408,7 +3614,7 @@ function ProductDetailView({ productId, initialTab, onBack, onEdit }) {
3408
3614
  } }] : []
3409
3615
  ]
3410
3616
  };
3411
- }, [entity, flat?.isForSale, flat?.isProduced, initialTab, provider, reload, t2]);
3617
+ }, [entity, hasMenu, hasRecipes, flat?.isForSale, flat?.isProduced, initialTab, provider, reload, t2, contributedTabs]);
3412
3618
  if (loading) {
3413
3619
  return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
3414
3620
  /* @__PURE__ */ jsx(SubpageHeader, { title: t2("inventory.nav.products"), onBack }),
@@ -5422,7 +5628,7 @@ function buildEntity(t2, actions) {
5422
5628
  type: "text",
5423
5629
  renderCell: (_v, row) => /* @__PURE__ */ jsxs("div", { className: "flex justify-end gap-1", onClick: (e) => e.stopPropagation(), children: [
5424
5630
  /* @__PURE__ */ jsxs(Tooltip, { children: [
5425
- /* @__PURE__ */ jsx(TooltipTrigger, { asChild: true, children: /* @__PURE__ */ jsx("span", { children: /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8", disabled: !row.recipeId, "aria-label": t2("inventory.recipes.print"), onClick: () => actions.onPrint(row), children: /* @__PURE__ */ jsx(Printer, { className: "h-4 w-4" }) }) }) }),
5631
+ /* @__PURE__ */ jsx(TooltipTrigger, { asChild: true, children: /* @__PURE__ */ jsx("span", { className: "hidden md:inline-flex", children: /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8", disabled: !row.recipeId, "aria-label": t2("inventory.recipes.print"), onClick: () => actions.onPrint(row), children: /* @__PURE__ */ jsx(Printer, { className: "h-4 w-4" }) }) }) }),
5426
5632
  /* @__PURE__ */ jsx(TooltipContent, { children: row.recipeId ? t2("inventory.recipes.print") : t2("inventory.recipes.printNoSheet") })
5427
5633
  ] }),
5428
5634
  /* @__PURE__ */ jsxs(Tooltip, { children: [
@@ -5555,6 +5761,20 @@ function RecipesView({ onOpenProduct }) {
5555
5761
  }
5556
5762
  return /* @__PURE__ */ jsxs(TooltipProvider, { delayDuration: 300, children: [
5557
5763
  /* @__PURE__ */ jsx("div", { ref: scrollAnchor }),
5764
+ /* @__PURE__ */ jsx(PageHeaderActions, { children: /* @__PURE__ */ jsx(
5765
+ PageActions,
5766
+ {
5767
+ actions: [{
5768
+ id: "new-recipe",
5769
+ label: t2("inventory.recipes.newRecipe"),
5770
+ icon: Plus,
5771
+ intent: "primary",
5772
+ creates: true,
5773
+ permission: { feature: "inventory", action: "edit" },
5774
+ onSelect: () => setPicking(true)
5775
+ }]
5776
+ }
5777
+ ) }),
5558
5778
  /* @__PURE__ */ jsx(
5559
5779
  CrudListView,
5560
5780
  {
@@ -5567,10 +5787,6 @@ function RecipesView({ onOpenProduct }) {
5567
5787
  facets,
5568
5788
  activeFilters: filters,
5569
5789
  onFacetChange: (field, value) => setFilters((prev) => ({ ...prev, [field]: value })),
5570
- headerActions: /* @__PURE__ */ jsx(PermissionGate, { feature: "inventory", action: "edit", children: /* @__PURE__ */ jsxs(Button, { onClick: () => setPicking(true), children: [
5571
- /* @__PURE__ */ jsx(Plus, { className: "mr-1.5 h-4 w-4" }),
5572
- t2("inventory.recipes.newRecipe")
5573
- ] }) }),
5574
5790
  onRowClick: (row) => onOpenProduct(row.productId),
5575
5791
  emptyMessage: t2("inventory.recipes.noRecipes"),
5576
5792
  onRefresh: () => load(),
@@ -5622,7 +5838,7 @@ function parseQuantity(raw) {
5622
5838
  if (!Number.isFinite(parsed) || parsed < 0) return void 0;
5623
5839
  return parsed;
5624
5840
  }
5625
- var CountedField = React4.memo(function CountedField2({
5841
+ var CountedField = React6.memo(function CountedField2({
5626
5842
  entry,
5627
5843
  value,
5628
5844
  onChange,
@@ -6207,47 +6423,6 @@ function BulkProductEditView() {
6207
6423
  }
6208
6424
  );
6209
6425
  }
6210
- var INVENTORY_MODULE_DEFAULTS = {
6211
- recipes: true
6212
- };
6213
- var INVENTORY_DEFAULTS = {
6214
- lowStockAlerts: true,
6215
- requireReason: true,
6216
- autoDeduct: false,
6217
- requireSku: false,
6218
- allowNegative: false,
6219
- lowStockEmail: false,
6220
- expiryWarnings: true
6221
- };
6222
- function InventoryGeneralSettings({ config }) {
6223
- const t2 = useTranslation();
6224
- const s = useTenantPluginSettings("inventory", INVENTORY_DEFAULTS);
6225
- const mods = useTenantPluginSettings("inventory", INVENTORY_MODULE_DEFAULTS);
6226
- return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
6227
- config?.modules.recipes && /* @__PURE__ */ jsx(SettingsGroup, { title: t2("inventory.settings.modules"), description: t2("inventory.settings.modulesDesc"), children: /* @__PURE__ */ jsx(
6228
- ToggleRow,
6229
- {
6230
- label: t2("inventory.settings.recipes"),
6231
- description: t2("inventory.settings.recipesDesc"),
6232
- checked: mods.get("recipes"),
6233
- onChange: (v) => mods.set("recipes", v)
6234
- }
6235
- ) }),
6236
- /* @__PURE__ */ jsxs(SettingsGroup, { title: t2("inventory.settings.stockManagement"), description: t2("inventory.settings.stockManagementDesc"), children: [
6237
- /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.lowStockAlerts"), description: t2("inventory.settings.lowStockAlertsDesc"), checked: s.get("lowStockAlerts"), onChange: (v) => s.set("lowStockAlerts", v) }),
6238
- /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.requireReason"), description: t2("inventory.settings.requireReasonDesc"), checked: s.get("requireReason"), onChange: (v) => s.set("requireReason", v) }),
6239
- /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.autoDeduct"), description: t2("inventory.settings.autoDeductDesc"), checked: s.get("autoDeduct"), onChange: (v) => s.set("autoDeduct", v) })
6240
- ] }),
6241
- /* @__PURE__ */ jsxs(SettingsGroup, { title: t2("inventory.settings.products"), description: t2("inventory.settings.productsDesc"), children: [
6242
- /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.requireSku"), description: t2("inventory.settings.requireSkuDesc"), checked: s.get("requireSku"), onChange: (v) => s.set("requireSku", v) }),
6243
- /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.allowNegative"), description: t2("inventory.settings.allowNegativeDesc"), checked: s.get("allowNegative"), onChange: (v) => s.set("allowNegative", v) })
6244
- ] }),
6245
- /* @__PURE__ */ jsxs(SettingsGroup, { title: t2("inventory.settings.notifications"), description: t2("inventory.settings.notificationsDesc"), children: [
6246
- /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.lowStockEmail"), description: t2("inventory.settings.lowStockEmailDesc"), checked: s.get("lowStockEmail"), onChange: (v) => s.set("lowStockEmail", v) }),
6247
- /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.expiryWarnings"), description: t2("inventory.settings.expiryWarningsDesc"), checked: s.get("expiryWarnings"), onChange: (v) => s.set("expiryWarnings", v) })
6248
- ] })
6249
- ] });
6250
- }
6251
6426
  var STEPS = [
6252
6427
  { id: "welcome", icon: Package, titleKey: "inventory.onboarding.welcome", descKey: "inventory.onboarding.description" },
6253
6428
  { id: "units", icon: Ruler, titleKey: "inventory.onboarding.units.title", descKey: "inventory.onboarding.units.description" },
@@ -6297,7 +6472,7 @@ function InventoryOnboarding({ onComplete }) {
6297
6472
  ] })
6298
6473
  ] }) });
6299
6474
  }
6300
- function buildNav(config, view, navigate, t2) {
6475
+ function buildNav(config, view, navigate, t2, house) {
6301
6476
  const items = [
6302
6477
  // Tudo o que se faz COM o catálogo mora aqui: a lista, como ela se agrupa
6303
6478
  // (Categorias) e como se mexe em muitos de uma vez. A primeira filha
@@ -6315,7 +6490,7 @@ function buildNav(config, view, navigate, t2) {
6315
6490
  active: view.startsWith("products") || view === "product-categories" || view === "bulk-products",
6316
6491
  children: [
6317
6492
  { id: "products-new", kind: "action", label: t2("inventory.nav.new"), active: view === "products-new", onClick: () => navigate("products-new") },
6318
- { id: "products-list", label: t2("inventory.nav.products"), defaultChild: true, active: view === "products-list" || view.startsWith("products-detail:") || view.startsWith("products-stock:"), onClick: () => navigate("products-list") },
6493
+ { id: "products-list", label: t2("inventory.nav.products"), defaultChild: true, active: view === "products-list" || view.startsWith("products-") && view.includes(":") && !view.startsWith("products-recipe:"), onClick: () => navigate("products-list") },
6319
6494
  { id: "product-categories", label: t2("registry.product-categories"), active: view === "product-categories", onClick: () => navigate("product-categories") },
6320
6495
  { id: "bulk-products", label: "Altera\xE7\xE3o em massa", active: view === "bulk-products", onClick: () => navigate("bulk-products") }
6321
6496
  ]
@@ -6364,7 +6539,7 @@ function buildNav(config, view, navigate, t2) {
6364
6539
  // panela rende.
6365
6540
  { id: "transfers", label: "Transfer\xEAncias", icon: "ArrowLeftRight", active: view === "transfers", onClick: () => navigate("transfers") },
6366
6541
  { id: "stock-indicators", label: "Indicadores", icon: "Target", active: view === "stock-indicators", onClick: () => navigate("stock-indicators") },
6367
- { id: "requisitions", label: "Requisi\xE7\xF5es", icon: "ClipboardList", active: view === "requisitions", onClick: () => navigate("requisitions") },
6542
+ ...house.requisitions ? [{ id: "requisitions", label: "Requisi\xE7\xF5es", icon: "ClipboardList", active: view === "requisitions", onClick: () => navigate("requisitions") }] : [],
6368
6543
  // Fichas Técnicas sits here, after Requisições, as in V1 (pushed below
6369
6544
  // only when the house uses recipes).
6370
6545
  ...config.modules.recipes ? [{
@@ -6382,13 +6557,13 @@ function buildNav(config, view, navigate, t2) {
6382
6557
  //
6383
6558
  // "Nova contagem" não está na navegação: era uma AÇÃO dentro de um menu, e
6384
6559
  // a lista de contagens já tem o "+" no cabeçalho dela.
6385
- {
6560
+ ...house.usageAudit ? [{
6386
6561
  id: "stock-usage-audit",
6387
6562
  label: t2("inventory.nav.usageAudit"),
6388
6563
  icon: "ScanSearch",
6389
6564
  active: view === "stock-usage-audit",
6390
6565
  onClick: () => navigate("stock-usage-audit")
6391
- },
6566
+ }] : [],
6392
6567
  // Produção fica de fora por decisão do founder: são seis telas e um assunto
6393
6568
  // próprio, não um buraco do estoque.
6394
6569
  //
@@ -6403,11 +6578,12 @@ function buildNav(config, view, navigate, t2) {
6403
6578
  active: ["stock-locations", "temperature"].some((v) => view === v),
6404
6579
  children: [
6405
6580
  { id: "stock-locations", label: "Locais", defaultChild: true, active: view === "stock-locations", onClick: () => navigate("stock-locations") },
6406
- { id: "temperature", label: "Temperatura", active: view === "temperature", onClick: () => navigate("temperature") }
6581
+ ...house.temperature ? [{ id: "temperature", label: "Temperatura", active: view === "temperature", onClick: () => navigate("temperature") }] : []
6407
6582
  ]
6408
6583
  }
6409
6584
  ];
6410
6585
  for (const extra of config.extraViews) {
6586
+ if (house.hiddenViews.includes(extra.id)) continue;
6411
6587
  const own = [extra.id, ...(extra.children ?? []).map((c) => c.id)];
6412
6588
  const hits = (id) => view === id || view.startsWith(`${id}/`) || view.startsWith(`${id}:`);
6413
6589
  items.push({
@@ -6431,7 +6607,7 @@ function buildNav(config, view, navigate, t2) {
6431
6607
  }
6432
6608
  function InventoryPage({ config: appConfig, provider, store, registries }) {
6433
6609
  const t2 = useTranslation();
6434
- const houseModules = useTenantPluginSettings("inventory", INVENTORY_MODULE_DEFAULTS);
6610
+ const houseModules = useInventoryHouseModules();
6435
6611
  const recipesOn = houseModules.get("recipes");
6436
6612
  const config = useMemo(
6437
6613
  () => appConfig.modules.recipes && recipesOn ? appConfig : { ...appConfig, modules: { ...appConfig.modules, recipes: false } },
@@ -6452,15 +6628,20 @@ function InventoryPage({ config: appConfig, provider, store, registries }) {
6452
6628
  "products-recipe": 1,
6453
6629
  settings: 1
6454
6630
  }, "dashboard");
6455
- const checkExistingData = React4.useCallback(
6631
+ const checkExistingData = React6.useCallback(
6456
6632
  async () => (await provider.getProducts({ pageSize: 1 })).total > 0,
6457
6633
  [provider]
6458
6634
  );
6459
6635
  const onboarding = usePluginOnboardingGate("saas-core:inventory-onboarded", checkExistingData);
6460
6636
  const isSettings = view === "settings";
6461
6637
  const isSummary = view === "dashboard" || view === "";
6462
- const nav = buildNav(config, view, navigate, t2);
6463
- const completeOnboarding = React4.useCallback(async () => {
6638
+ const nav = buildNav(config, view, navigate, t2, {
6639
+ requisitions: houseModules.get("requisitions"),
6640
+ usageAudit: houseModules.get("usageAudit"),
6641
+ temperature: houseModules.get("temperature"),
6642
+ hiddenViews: houseModules.get("hiddenViews")
6643
+ });
6644
+ const completeOnboarding = React6.useCallback(async () => {
6464
6645
  await provider.ensureReferenceData?.();
6465
6646
  onboarding.complete();
6466
6647
  }, [provider, onboarding.complete]);
@@ -6525,9 +6706,14 @@ function InventoryPage({ config: appConfig, provider, store, registries }) {
6525
6706
  { id: "products-new", render: () => /* @__PURE__ */ jsx(ProductCrudForm, { onSaved: () => navigate("products-list") }) },
6526
6707
  { id: "products-edit", render: ({ id }) => /* @__PURE__ */ jsx(ProductCrudForm, { editId: id, onSaved: () => navigate(`products-detail:${id}`) }) },
6527
6708
  { id: "products-detail", render: ({ id }) => /* @__PURE__ */ jsx(ProductDetailView, { productId: id, onBack: () => navigate("products-list"), onEdit: () => navigate(`products-edit:${id}`) }) },
6528
- // The tab writes `/products/<id>/stock` into the URL, so that link has to
6529
- // come back to the same page with the tab open.
6530
- { id: "products-stock", render: ({ id }) => /* @__PURE__ */ jsx(ProductDetailView, { productId: id, initialTab: "stock", onBack: () => navigate("products-list"), onEdit: () => navigate(`products-edit:${id}`) }) }
6709
+ // Every tab writes `/products/<id>/<tab>` into the URL, so each of those
6710
+ // links has to come back to the same page with that tab open. Only
6711
+ // `stock` had a route: reloading on any other tab fell through to the
6712
+ // module's first screen.
6713
+ ...["stock", "variants", "barcodes", "complements", ...config.productDetailTabs.map((tab) => tab.id)].map((tab) => ({
6714
+ id: `products-${tab}`,
6715
+ render: ({ id }) => /* @__PURE__ */ jsx(ProductDetailView, { productId: id, initialTab: tab, onBack: () => navigate("products-list"), onEdit: () => navigate(`products-edit:${id}`) })
6716
+ }))
6531
6717
  ] : [],
6532
6718
  { id: "stock-entry", render: () => /* @__PURE__ */ jsx(StockMovementView, { defaultType: "entry", onSaved: () => navigate("stock-history") }) },
6533
6719
  { id: "stock-exit", render: () => /* @__PURE__ */ jsx(StockMovementView, { defaultType: "exit", onSaved: () => navigate("stock-history") }) },
@@ -6567,14 +6753,19 @@ function InventoryPage({ config: appConfig, provider, store, registries }) {
6567
6753
  // topo e largá-lo numa fileira própria, encostado à direita do corpo. Com
6568
6754
  // isso, Locais e Categorias eram as duas únicas listas do Estoque cujo botão
6569
6755
  // de criar ficava em outro lugar que o de Produtos e o de Contagens.
6570
- { id: "product-categories", render: () => {
6571
- const reg = registries?.find((r) => r.id === "product-categories");
6572
- return reg ? /* @__PURE__ */ jsx(RegistryCrudView, { registry: reg, basePath: "/inventory/product-categories" }) : /* @__PURE__ */ jsx(ComingSoon, { title: "Categorias", summary: "Como o cat\xE1logo se agrupa.", v1Path: "Estoque \u2192 Produtos \u2192 Categorias", missing: ["Este aplicativo n\xE3o declarou o cadastro de categorias"] });
6573
- } },
6574
- { id: "stock-locations", render: () => {
6575
- const reg = registries?.find((r) => r.id === "stock-locations");
6576
- return reg ? /* @__PURE__ */ jsx(RegistryCrudView, { registry: reg, basePath: "/inventory/stock-locations" }) : /* @__PURE__ */ jsx(StockLocationsView, {});
6577
- } },
6756
+ // The module writes a view id as a path (`product-categories` is
6757
+ // `/product/categories`), so the registry's base path and its own
6758
+ // new/edit addresses have to be spelled the same way or "New" lands on 404.
6759
+ ...["", "-new", "-edit", "-detail"].flatMap((suffix) => [
6760
+ { id: `product-categories${suffix}`, render: () => {
6761
+ const reg = registries?.find((r) => r.id === "product-categories");
6762
+ return reg ? /* @__PURE__ */ jsx(RegistryCrudView, { registry: reg, basePath: "/inventory/product/categories" }) : /* @__PURE__ */ jsx(ComingSoon, { title: "Categorias", summary: "Como o cat\xE1logo se agrupa.", v1Path: "Estoque \u2192 Produtos \u2192 Categorias", missing: ["Este aplicativo n\xE3o declarou o cadastro de categorias"] });
6763
+ } },
6764
+ { id: `stock-locations${suffix}`, render: () => {
6765
+ const reg = registries?.find((r) => r.id === "stock-locations");
6766
+ return reg ? /* @__PURE__ */ jsx(RegistryCrudView, { registry: reg, basePath: "/inventory/stock/locations" }) : /* @__PURE__ */ jsx(StockLocationsView, {});
6767
+ } }
6768
+ ]),
6578
6769
  { id: "temperature", render: () => /* @__PURE__ */ jsx(TemperatureView, {}) },
6579
6770
  { id: "bulk-products", render: () => /* @__PURE__ */ jsx(BulkProductEditView, {}) },
6580
6771
  { id: "stock-indicators", render: () => /* @__PURE__ */ jsx(StockIndicatorsView, {}) },
@@ -8396,6 +8587,42 @@ function createSupabaseInventoryProvider() {
8396
8587
  });
8397
8588
  if (error) throw new Error(error.message);
8398
8589
  },
8590
+ async getProductVariants(productId) {
8591
+ const { core } = getClients();
8592
+ const { data } = await core.from("product_variants").select("id, name, sku, is_default, is_active, sort_order").eq("product_id", productId).order("sort_order", { ascending: true }).order("created_at", { ascending: true });
8593
+ const rows = data ?? [];
8594
+ if (rows.length === 0) return [];
8595
+ const balances = await core.from("stock_balances").select("variant_id, on_hand").in("variant_id", rows.map((r) => r.id));
8596
+ const onHand = /* @__PURE__ */ new Map();
8597
+ for (const b of balances.data ?? []) {
8598
+ onHand.set(b.variant_id, (onHand.get(b.variant_id) ?? 0) + (Number(b.on_hand) || 0));
8599
+ }
8600
+ return rows.map((r) => ({
8601
+ id: r.id,
8602
+ name: r.name,
8603
+ sku: r.sku ?? void 0,
8604
+ isDefault: !!r.is_default,
8605
+ isActive: r.is_active !== false,
8606
+ onHand: onHand.get(r.id) ?? 0
8607
+ }));
8608
+ },
8609
+ async addProductVariant(productId, input) {
8610
+ const { core } = getClients();
8611
+ const existing = await provider.getProductVariants(productId);
8612
+ const { error } = await core.from("product_variants").insert({
8613
+ tenant_id: getTenantId(),
8614
+ product_id: productId,
8615
+ name: input.name.trim(),
8616
+ sku: input.sku?.trim() || null,
8617
+ sort_order: existing.length
8618
+ });
8619
+ if (error) throw new Error(error.message);
8620
+ },
8621
+ async setProductVariantActive(id, active) {
8622
+ const { core } = getClients();
8623
+ const { error } = await core.from("product_variants").update({ is_active: active }).eq("id", id);
8624
+ if (error) throw new Error(error.message);
8625
+ },
8399
8626
  async removeProductBarcode(id) {
8400
8627
  const { core } = getClients();
8401
8628
  const { error } = await core.from(T.productBarcodes).delete().eq("id", id);
@@ -10386,6 +10613,11 @@ var en = {
10386
10613
  "inventory.settings.modulesDesc": "What this house shows inside Inventory",
10387
10614
  "inventory.settings.recipes": "Recipes",
10388
10615
  "inventory.settings.recipesDesc": "The bill of materials that says what each product consumes",
10616
+ "inventory.settings.requisitions": "Requisitions",
10617
+ "inventory.settings.requisitionsDesc": "Internal requests for material between areas",
10618
+ "inventory.settings.usageAuditDesc": "What left stock against what was sold",
10619
+ "inventory.settings.temperature": "Temperature",
10620
+ "inventory.settings.temperatureDesc": "Temperature log for refrigerated locations",
10389
10621
  "inventory.settings.stockManagement": "Stock Management",
10390
10622
  "inventory.settings.stockManagementDesc": "How stock levels are tracked",
10391
10623
  "inventory.settings.lowStockAlerts": "Low stock alerts",
@@ -10508,6 +10740,18 @@ var en = {
10508
10740
  "inventory.productType.sale": "For sale",
10509
10741
  "inventory.productType.intermediate": "Intermediate",
10510
10742
  "inventory.productType.asset": "Asset",
10743
+ "inventory.variants.tab": "Variants",
10744
+ "inventory.variants.name": "Variant",
10745
+ "inventory.variants.namePlaceholder": "E.g. M, L, XL, Blue",
10746
+ "inventory.variants.sku": "SKU",
10747
+ "inventory.variants.skuPlaceholder": "Optional",
10748
+ "inventory.variants.default": "Default",
10749
+ "inventory.variants.empty": "No variants. The product is sold and stocked as a single item.",
10750
+ "inventory.variants.duplicate": "A variant with this name already exists.",
10751
+ "inventory.variants.loadFailed": "Could not load the variants.",
10752
+ "inventory.variants.saveFailed": "Could not save the variant.",
10753
+ "inventory.variants.unavailable": "Variants are not available in this workspace.",
10754
+ "inventory.variants.onHand": "{{count}} in stock",
10511
10755
  "inventory.barcodes.tab": "Barcodes",
10512
10756
  "inventory.barcodes.code": "Barcode",
10513
10757
  "inventory.barcodes.codePlaceholder": "Type or scan",
@@ -11223,6 +11467,11 @@ var ptBR = {
11223
11467
  "inventory.settings.modulesDesc": "O que desta casa aparece no Estoque",
11224
11468
  "inventory.settings.recipes": "Receitas",
11225
11469
  "inventory.settings.recipesDesc": "A ficha t\xE9cnica que diz quanto cada produto consome",
11470
+ "inventory.settings.requisitions": "Requisi\xE7\xF5es",
11471
+ "inventory.settings.requisitionsDesc": "Pedidos internos de material entre setores",
11472
+ "inventory.settings.usageAuditDesc": "O que saiu do estoque contra o que foi vendido",
11473
+ "inventory.settings.temperature": "Temperatura",
11474
+ "inventory.settings.temperatureDesc": "Registro de temperatura dos locais refrigerados",
11226
11475
  "inventory.settings.stockManagement": "Gest\xE3o de Estoque",
11227
11476
  "inventory.settings.stockManagementDesc": "Como os n\xEDveis de estoque s\xE3o rastreados",
11228
11477
  "inventory.settings.lowStockAlerts": "Alertas de estoque baixo",
@@ -11345,6 +11594,18 @@ var ptBR = {
11345
11594
  "inventory.productType.sale": "Venda",
11346
11595
  "inventory.productType.intermediate": "Intermedi\xE1rio",
11347
11596
  "inventory.productType.asset": "Patrim\xF4nio",
11597
+ "inventory.variants.tab": "Varia\xE7\xF5es",
11598
+ "inventory.variants.name": "Varia\xE7\xE3o",
11599
+ "inventory.variants.namePlaceholder": "Ex.: M, G, GG, Azul",
11600
+ "inventory.variants.sku": "SKU",
11601
+ "inventory.variants.skuPlaceholder": "Opcional",
11602
+ "inventory.variants.default": "Padr\xE3o",
11603
+ "inventory.variants.empty": "Nenhuma varia\xE7\xE3o. O produto \xE9 vendido e estocado como um item s\xF3.",
11604
+ "inventory.variants.duplicate": "J\xE1 existe uma varia\xE7\xE3o com esse nome.",
11605
+ "inventory.variants.loadFailed": "N\xE3o foi poss\xEDvel carregar as varia\xE7\xF5es.",
11606
+ "inventory.variants.saveFailed": "N\xE3o foi poss\xEDvel salvar a varia\xE7\xE3o.",
11607
+ "inventory.variants.unavailable": "Varia\xE7\xF5es n\xE3o est\xE3o dispon\xEDveis nesta conta.",
11608
+ "inventory.variants.onHand": "{{count}} em estoque",
11348
11609
  "inventory.barcodes.tab": "C\xF3digos de Barras",
11349
11610
  "inventory.barcodes.code": "C\xF3digo de barras",
11350
11611
  "inventory.barcodes.codePlaceholder": "Digite ou bipe",
@@ -20864,6 +21125,261 @@ $$;
20864
21125
  REVOKE ALL ON FUNCTION public.inventory_save_recipe(jsonb) FROM PUBLIC, anon;
20865
21126
  GRANT EXECUTE ON FUNCTION public.inventory_save_recipe(jsonb) TO authenticated;
20866
21127
  `;
21128
+ var MIGRATION_026_STOCK_COST_KNOWS_ITS_CURRENCY = `-- ============================================================================
21129
+ -- 026 \u2014 stock cost knows its currency
21130
+ --
21131
+ -- \`unit_cost\` was a bare number. With a shop in Rio and one in Barcelona, and
21132
+ -- shirts paid for in dollars, three currencies would have met in one weighted
21133
+ -- average.
21134
+ --
21135
+ -- The rule: the cost a position carries is ALWAYS in the currency of the unit
21136
+ -- that holds the stock, so every sum and average that already exists stays
21137
+ -- right. What was actually paid, when it was paid in something else, is kept
21138
+ -- beside it (\`source_currency\`, \`source_unit_cost\`, \`fx_rate\`) and converted
21139
+ -- once, on the way in.
21140
+ --
21141
+ -- A receipt says the currency on the document (or the line):
21142
+ -- inventory_receive(loc, lines, '{"currency":"USD","fx_rate":5.42}')
21143
+ -- With no rate given, the tenant's rate for the day is used; with none on
21144
+ -- file, the receipt is refused rather than booked at a made-up cost.
21145
+ --
21146
+ -- Moving stock between units of different currencies is refused for now: the
21147
+ -- same cost number would land in the other currency unconverted.
21148
+ -- ============================================================================
21149
+
21150
+ DO $$
21151
+ BEGIN
21152
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
21153
+ WHERE table_schema = 'public' AND table_name = 'plg_inventory_stock_movements' AND column_name = 'currency') THEN
21154
+ ALTER TABLE public.plg_inventory_stock_movements ADD COLUMN currency text NOT NULL DEFAULT 'BRL';
21155
+ ALTER TABLE public.plg_inventory_stock_movements ALTER COLUMN currency DROP DEFAULT;
21156
+ END IF;
21157
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
21158
+ WHERE table_schema = 'public' AND table_name = 'plg_inventory_stock_positions' AND column_name = 'currency') THEN
21159
+ ALTER TABLE public.plg_inventory_stock_positions ADD COLUMN currency text NOT NULL DEFAULT 'BRL';
21160
+ ALTER TABLE public.plg_inventory_stock_positions ALTER COLUMN currency DROP DEFAULT;
21161
+ END IF;
21162
+ END $$;
21163
+ ALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS source_currency text;
21164
+ ALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS source_unit_cost numeric;
21165
+ ALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS fx_rate numeric;
21166
+
21167
+ COMMENT ON COLUMN public.plg_inventory_stock_movements.currency IS 'Currency of unit_cost/total_cost: the unit that holds the stock.';
21168
+ COMMENT ON COLUMN public.plg_inventory_stock_movements.source_currency IS 'Currency the goods were paid in, when not the unit''s.';
21169
+ COMMENT ON COLUMN public.plg_inventory_stock_movements.source_unit_cost IS 'Unit cost as paid, in source_currency.';
21170
+ COMMENT ON COLUMN public.plg_inventory_stock_movements.fx_rate IS '1 source_currency = fx_rate of the unit''s currency, as used for this entry.';
21171
+ COMMENT ON COLUMN public.plg_inventory_stock_positions.currency IS 'Currency of unit_cost: the unit that holds this balance.';
21172
+
21173
+ CREATE OR REPLACE FUNCTION app.inventory_movements_currency()
21174
+ RETURNS trigger
21175
+ LANGUAGE plpgsql
21176
+ SECURITY DEFINER
21177
+ SET search_path TO ''
21178
+ AS $function$
21179
+ DECLARE
21180
+ v_unit_cur text := app.unit_currency(NEW.tenant_id, NEW.unit_id);
21181
+ v_dst_unit uuid;
21182
+ v_src text := nullif(upper(btrim(coalesce(NEW.source_currency, ''))), '');
21183
+ BEGIN
21184
+ NEW.currency := v_unit_cur;
21185
+
21186
+ IF NEW.destination_location_id IS NOT NULL THEN
21187
+ SELECT l.unit_id INTO v_dst_unit FROM public.plg_inventory_stock_locations l WHERE l.id = NEW.destination_location_id;
21188
+ IF app.unit_currency(NEW.tenant_id, v_dst_unit) <> v_unit_cur THEN
21189
+ RAISE EXCEPTION 'Transfer\xEAncia entre filiais de moedas diferentes (% \u2192 %) ainda n\xE3o \xE9 suportada: d\xEA sa\xEDda em uma e entrada na outra, informando o custo.',
21190
+ v_unit_cur, app.unit_currency(NEW.tenant_id, v_dst_unit)
21191
+ USING ERRCODE = '0A000';
21192
+ END IF;
21193
+ END IF;
21194
+
21195
+ IF v_src IS NULL OR v_src = v_unit_cur THEN
21196
+ NEW.source_currency := NULL; NEW.source_unit_cost := NULL; NEW.fx_rate := NULL;
21197
+ RETURN NEW;
21198
+ END IF;
21199
+
21200
+ NEW.source_currency := v_src;
21201
+ NEW.source_unit_cost := coalesce(NEW.source_unit_cost, NEW.unit_cost, 0);
21202
+ -- The tenant's rate table belongs to financial, which may not be installed.
21203
+ IF NEW.fx_rate IS NULL AND to_regprocedure('public.plg_financial_fx_rate(uuid,text,text,date)') IS NOT NULL THEN
21204
+ EXECUTE 'SELECT public.plg_financial_fx_rate($1, $2, $3, $4)'
21205
+ INTO NEW.fx_rate USING NEW.tenant_id, v_src, v_unit_cur, NEW.movement_date;
21206
+ END IF;
21207
+ IF NEW.fx_rate IS NULL OR NEW.fx_rate <= 0 THEN
21208
+ RAISE EXCEPTION 'Informe a taxa de c\xE2mbio de % para % desta entrada (ou cadastre a taxa do dia).', v_src, v_unit_cur
21209
+ USING ERRCODE = '22023';
21210
+ END IF;
21211
+ NEW.unit_cost := round(NEW.source_unit_cost * NEW.fx_rate, 4);
21212
+ NEW.total_cost := abs(NEW.quantity) * NEW.unit_cost;
21213
+ RETURN NEW;
21214
+ END $function$;
21215
+
21216
+ -- Named to fire after \`plg_inventory_stock_movements_before_insert\`, which
21217
+ -- stamps the unit and the cost this converts.
21218
+ DROP TRIGGER IF EXISTS plg_inventory_stock_movements_currency ON public.plg_inventory_stock_movements;
21219
+ CREATE TRIGGER plg_inventory_stock_movements_currency
21220
+ BEFORE INSERT ON public.plg_inventory_stock_movements
21221
+ FOR EACH ROW EXECUTE FUNCTION app.inventory_movements_currency();
21222
+
21223
+ CREATE OR REPLACE FUNCTION app.inventory_positions_currency()
21224
+ RETURNS trigger
21225
+ LANGUAGE plpgsql
21226
+ SECURITY DEFINER
21227
+ SET search_path TO ''
21228
+ AS $function$
21229
+ DECLARE v_unit uuid;
21230
+ BEGIN
21231
+ SELECT l.unit_id INTO v_unit FROM public.plg_inventory_stock_locations l WHERE l.id = NEW.stock_location_id;
21232
+ NEW.currency := app.unit_currency(NEW.tenant_id, coalesce(v_unit, NEW.unit_id));
21233
+ RETURN NEW;
21234
+ END $function$;
21235
+ DROP TRIGGER IF EXISTS plg_inventory_stock_positions_currency ON public.plg_inventory_stock_positions;
21236
+ CREATE TRIGGER plg_inventory_stock_positions_currency
21237
+ BEFORE INSERT ON public.plg_inventory_stock_positions
21238
+ FOR EACH ROW EXECUTE FUNCTION app.inventory_positions_currency();
21239
+
21240
+ -- The receipt passes the document's currency through. Patched in place: the
21241
+ -- live body differs from the file that created it.
21242
+ DO $patch$
21243
+ DECLARE
21244
+ v_fn regprocedure := 'public.inventory_receive(uuid, jsonb, jsonb, text)'::regprocedure;
21245
+ v_def text := pg_get_functiondef(v_fn);
21246
+ v_cols constant text := 'idempotency_key, line_no, operation_id, metadata)';
21247
+ v_vals constant text := 'v_key, v_no, v_op.op_id, v_ln.declared)';
21248
+ BEGIN
21249
+ IF position('source_currency' in v_def) > 0 THEN
21250
+ RETURN; -- already passes it (a replay, or a later rewrite)
21251
+ END IF;
21252
+ IF position(v_cols in v_def) = 0 OR position(v_vals in v_def) = 0 THEN
21253
+ RAISE EXCEPTION '026: inventory_receive no longer has the INSERT this extends; patch it by hand';
21254
+ END IF;
21255
+ v_def := overlay(v_def PLACING 'idempotency_key, line_no, operation_id, metadata, source_currency, fx_rate)'
21256
+ FROM position(v_cols in v_def) FOR length(v_cols));
21257
+ v_def := overlay(v_def PLACING
21258
+ 'v_key, v_no, v_op.op_id, v_ln.declared,' || E'\\n'
21259
+ || ' nullif(upper(btrim(coalesce(v_line->>''currency'', v_doc->>''currency'', ''''))), ''''),' || E'\\n'
21260
+ || ' coalesce(nullif(v_line->>''fx_rate'', '''')::numeric, nullif(v_doc->>''fx_rate'', '''')::numeric))'
21261
+ FROM position(v_vals in v_def) FOR length(v_vals));
21262
+ EXECUTE v_def;
21263
+ END $patch$;
21264
+ `;
21265
+ var MIGRATION_027_A_RECEIPT_NAMES_THE_LINE_IT_CAME_FROM = `-- ============================================================================
21266
+ -- 027 \u2014 a receipt names the line it came from
21267
+ --
21268
+ -- Stock received because of a bill had no tie to the bill line: the ledger
21269
+ -- kept \`source_item_type\` / \`source_item_id\` columns, but \`inventory_receive\`
21270
+ -- never filled them, so "did this line enter stock?" was answered by guessing
21271
+ -- from the invoice number. A line of the receipt can now say where it came
21272
+ -- from, and the receipt writes it down.
21273
+ --
21274
+ -- Patched in place, as 026 did: the live body differs from the files.
21275
+ -- ============================================================================
21276
+
21277
+ DO $patch$
21278
+ DECLARE
21279
+ v_fn regprocedure := 'public.inventory_receive(uuid, jsonb, jsonb, text)'::regprocedure;
21280
+ v_def text := pg_get_functiondef(v_fn);
21281
+ v_cols constant text := 'metadata, source_currency, fx_rate)';
21282
+ v_vals constant text := 'nullif(v_doc->>''fx_rate'', '''')::numeric))';
21283
+ BEGIN
21284
+ IF position('source_item_id' in v_def) > 0 THEN
21285
+ RETURN; -- already writes it (a replay, or a later rewrite)
21286
+ END IF;
21287
+ IF position(v_cols in v_def) = 0 OR position(v_vals in v_def) = 0 THEN
21288
+ RAISE EXCEPTION '027: inventory_receive no longer has the INSERT this extends; patch it by hand';
21289
+ END IF;
21290
+ v_def := overlay(v_def PLACING 'metadata, source_currency, fx_rate, source_item_type, source_item_id)'
21291
+ FROM position(v_cols in v_def) FOR length(v_cols));
21292
+ v_def := overlay(v_def PLACING
21293
+ 'nullif(v_doc->>''fx_rate'', '''')::numeric),' || E'\\n'
21294
+ || ' nullif(v_line->>''source_item_type'', ''''), nullif(v_line->>''source_item_id'', '''')::uuid)'
21295
+ FROM position(v_vals in v_def) FOR length(v_vals));
21296
+ EXECUTE v_def;
21297
+ END $patch$;
21298
+
21299
+ CREATE INDEX IF NOT EXISTS plg_inventory_stock_movements_source_item_idx
21300
+ ON public.plg_inventory_stock_movements (tenant_id, source_item_id) WHERE source_item_id IS NOT NULL;
21301
+ `;
21302
+ var MIGRATION_028_A_RECEIPT_KEEPS_THE_VARIANT = `-- ============================================================================
21303
+ -- 028 \u2014 a receipt keeps the variant
21304
+ --
21305
+ -- \`inventory_receive\` read \`variant_id\` off each line, checked it belonged to
21306
+ -- the product, and then did not write it: every receipt landed on the
21307
+ -- product's default variant. A shirt received as 10 M and 9 G was 19 of
21308
+ -- "Padr\xE3o". The line's variant now reaches the ledger row, which is what the
21309
+ -- per-variant balance is computed from.
21310
+ --
21311
+ -- Patched in place, as 026 and 027 did.
21312
+ --
21313
+ -- The column itself was never in this plugin's chain (022 says so): pools got
21314
+ -- it by hand, a database built from zero did not. It is declared here, in the
21315
+ -- shape the pools have. The value is read off the line's JSON, which exists in
21316
+ -- every version of the function; \`v_ln.variant_id\` only exists where the
21317
+ -- parser was widened by hand.
21318
+ -- ============================================================================
21319
+
21320
+ ALTER TABLE public.plg_inventory_stock_movements
21321
+ ADD COLUMN IF NOT EXISTS variant_id uuid REFERENCES public.product_variants(id) ON DELETE SET NULL;
21322
+ CREATE INDEX IF NOT EXISTS plg_inventory_stock_movements_variant_idx
21323
+ ON public.plg_inventory_stock_movements (tenant_id, variant_id) WHERE variant_id IS NOT NULL;
21324
+
21325
+ DO $patch$
21326
+ DECLARE
21327
+ v_fn regprocedure := 'public.inventory_receive(uuid, jsonb, jsonb, text)'::regprocedure;
21328
+ v_def text := pg_get_functiondef(v_fn);
21329
+ v_cols constant text := 'source_item_type, source_item_id)';
21330
+ v_vals constant text := 'nullif(v_line->>''source_item_id'', '''')::uuid)';
21331
+ BEGIN
21332
+ IF position('source_item_type, source_item_id, variant_id)' in v_def) > 0 THEN
21333
+ RETURN; -- already writes it (a replay, or a later rewrite)
21334
+ END IF;
21335
+ IF position(v_cols in v_def) = 0 OR position(v_vals in v_def) = 0 THEN
21336
+ RAISE EXCEPTION '028: inventory_receive no longer has the INSERT this extends; patch it by hand';
21337
+ END IF;
21338
+ v_def := overlay(v_def PLACING 'source_item_type, source_item_id, variant_id)'
21339
+ FROM position(v_cols in v_def) FOR length(v_cols));
21340
+ v_def := overlay(v_def PLACING 'nullif(v_line->>''source_item_id'', '''')::uuid, nullif(v_line->>''variant_id'', '''')::uuid)'
21341
+ FROM position(v_vals in v_def) FOR length(v_vals));
21342
+ EXECUTE v_def;
21343
+ END $patch$;
21344
+ `;
21345
+ var MIGRATION_029_A_REVERSAL_UNDOES_THE_SAME_VARIANT = `-- ============================================================================
21346
+ -- 029 \u2014 a reversal undoes the same variant
21347
+ --
21348
+ -- 028 made a receipt keep the variant it was for. \`inventory_reverse\` copies
21349
+ -- the row it undoes and left the variant out, so undoing "6 of M" tried to
21350
+ -- take 6 from the product's default variant \u2014 which has none, and the
21351
+ -- reversal was refused ("would take variant \u2026 below zero"). A receipt by size
21352
+ -- could be made and never corrected.
21353
+ --
21354
+ -- The reversal now carries the variant of the row it reverses.
21355
+ -- Patched in place, like 026\u2013028.
21356
+ -- ============================================================================
21357
+
21358
+ DO $patch$
21359
+ DECLARE
21360
+ v_fn regprocedure := 'public.inventory_reverse(text, text)'::regprocedure;
21361
+ v_def text := pg_get_functiondef(v_fn);
21362
+ v_new text;
21363
+ -- The function has two INSERTs (one per branch); both copy the row they undo.
21364
+ v_cols constant text := 'reverses_movement_id, idempotency_key, line_no, operation_id, metadata)';
21365
+ v_vals1 constant text := 'jsonb_build_object(''reversed_movement_id'', r.id))';
21366
+ v_vals2 constant text := '''reversed_kind'', r.kind))';
21367
+ BEGIN
21368
+ IF position('r.variant_id' in v_def) > 0 THEN
21369
+ RETURN; -- already carries it (a replay, or a later rewrite)
21370
+ END IF;
21371
+ v_new := replace(v_def, v_cols, 'reverses_movement_id, idempotency_key, line_no, operation_id, metadata, variant_id)');
21372
+ v_new := replace(v_new, v_vals1, 'jsonb_build_object(''reversed_movement_id'', r.id), r.variant_id)');
21373
+ v_new := replace(v_new, v_vals2, '''reversed_kind'', r.kind), r.variant_id)');
21374
+ -- Every INSERT that gained the column must have gained the value.
21375
+ IF (length(v_new) - length(replace(v_new, 'metadata, variant_id)', ''))) / length('metadata, variant_id)')
21376
+ <> (length(v_new) - length(replace(v_new, ', r.variant_id)', ''))) / length(', r.variant_id)')
21377
+ OR position('metadata, variant_id)' in v_new) = 0 THEN
21378
+ RAISE EXCEPTION '029: inventory_reverse no longer has the INSERTs this extends; patch it by hand';
21379
+ END IF;
21380
+ EXECUTE v_new;
21381
+ END $patch$;
21382
+ `;
20867
21383
  var MIGRATIONS = [
20868
21384
  { id: "000_baseline", sql: MIGRATION_000_BASELINE },
20869
21385
  { id: "001_the_usage_that_left_stock_can_be_audited", sql: MIGRATION_001_THE_USAGE_THAT_LEFT_STOCK_CAN_BE_AUDITED },
@@ -20890,7 +21406,11 @@ var MIGRATIONS = [
20890
21406
  { id: "022_receber_estoque_volta_a_funcionar", sql: MIGRATION_022_RECEBER_ESTOQUE_VOLTA_A_FUNCIONAR },
20891
21407
  { id: "023_a_receita_se_escreve_com_permissao", sql: MIGRATION_023_A_RECEITA_SE_ESCREVE_COM_PERMISSAO },
20892
21408
  { id: "024_a_categoria_do_produto_e_do_catalogo", sql: MIGRATION_024_A_CATEGORIA_DO_PRODUTO_E_DO_CATALOGO },
20893
- { id: "025_a_ficha_tecnica_por_estacao", sql: MIGRATION_025_A_FICHA_TECNICA_POR_ESTACAO }
21409
+ { id: "025_a_ficha_tecnica_por_estacao", sql: MIGRATION_025_A_FICHA_TECNICA_POR_ESTACAO },
21410
+ { id: "026_stock_cost_knows_its_currency", sql: MIGRATION_026_STOCK_COST_KNOWS_ITS_CURRENCY },
21411
+ { id: "027_a_receipt_names_the_line_it_came_from", sql: MIGRATION_027_A_RECEIPT_NAMES_THE_LINE_IT_CAME_FROM },
21412
+ { id: "028_a_receipt_keeps_the_variant", sql: MIGRATION_028_A_RECEIPT_KEEPS_THE_VARIANT },
21413
+ { id: "029_a_reversal_undoes_the_same_variant", sql: MIGRATION_029_A_REVERSAL_UNDOES_THE_SAME_VARIANT }
20894
21414
  ];
20895
21415
 
20896
21416
  // src/index.ts
@@ -20930,6 +21450,7 @@ function resolveConfig(options) {
20930
21450
  locations: options?.locations ?? [],
20931
21451
  moduleNav: options?.moduleNav,
20932
21452
  extraViews: options?.extraViews ?? [],
21453
+ productDetailTabs: options?.productDetailTabs ?? [],
20933
21454
  documentModal: options?.documentModal
20934
21455
  };
20935
21456
  }
@@ -20945,7 +21466,7 @@ function createInventoryPlugin(options) {
20945
21466
  const provider = options?.dataProvider ?? createInventoryDataProvider();
20946
21467
  const store = createInventoryStore(provider);
20947
21468
  const dashboardWidgets = createInventoryDashboardWidgets({ config, provider, store });
20948
- const PageComponent = () => React4.createElement(InventoryPage, { config, provider, store, registries: inventoryRegistries });
21469
+ const PageComponent = () => React6.createElement(InventoryPage, { config, provider, store, registries: inventoryRegistries });
20949
21470
  return {
20950
21471
  id: "inventory",
20951
21472
  // Como esta conta refaz o módulo com as opções DELA. Ver PluginRebuild:
@@ -21114,9 +21635,9 @@ function createInventoryPlugin(options) {
21114
21635
  // Categories, Units and Suppliers all existed in code and appeared
21115
21636
  // nowhere. financial and crm already render through it.
21116
21637
  component: (() => {
21117
- const Tab = () => React4.createElement(PluginSettingsPanel, {
21638
+ const Tab = () => React6.createElement(PluginSettingsPanel, {
21118
21639
  title: config.labels.pageTitle,
21119
- generalSettings: React4.createElement(InventoryGeneralSettings, { config }),
21640
+ generalSettings: React6.createElement(InventoryGeneralSettings, { config }),
21120
21641
  registries: inventoryRegistries,
21121
21642
  hostPluginId: "inventory",
21122
21643
  routeBase: "/settings/inventory"