@fayz-ai/plugin-inventory 0.15.5 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
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, 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, useTenantPluginSettings, useModuleNavigation, usePluginOnboardingGate, PluginOnboardingGateSkeleton, createViewRouter, RegistryCrudView, ModuleActionBar, SettingsGroup, ToggleRow, usePermissionOptional, CrudListView, useLimitGuard, invalidateRelationOptions, CrudFormSkeleton, CrudFormPage, CrudDetailPage, formatCurrency, resolveDocumentOpenerFor, useRememberedList, usePersistedState, useScrollMemory, useAgentSurface, PageActions, invalidateLimit, RecordPickerModal } from '@fayz-ai/admin';
1
+ import React14, { 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, ModalHeader, ModalTitle, ModalBody, Checkbox, ModalFooter, AiActionButton, ModalDescription, Tooltip, TooltipTrigger, TooltipContent, CardContent, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, StockEntryForm, Skeleton, CurrencyInput, openPrintWindow, printDocument, createXlsxReport, useSaveBar, Dropdown, DropdownTrigger, DropdownContent, DropdownItem, DropdownSeparator, 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
- 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';
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, Sparkles, X, Upload, Building2, Utensils, Clock, Printer, Pencil, Star, Undo2, ArrowUp, ArrowDown, MoreVertical, PackageX } from 'lucide-react';
7
7
  import { createStore as createStore$1 } from 'zustand/vanilla';
8
8
 
9
9
  // src/index.ts
@@ -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,144 +1753,88 @@ function ProductListView({ onNew, onOpen }) {
1633
1753
  )
1634
1754
  ] });
1635
1755
  }
1636
- function ProductCrudForm({ editId, onSaved }) {
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 = React14.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 }) {
1637
1783
  const t2 = useTranslation();
1638
- const { productTypes, currency } = useInventoryConfig();
1639
- const provider = useInventoryProvider();
1640
- const createProduct = useInventoryStore((s) => s.createProduct);
1641
- const guardProducts = useLimitGuard("products");
1642
- 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(() => {
1647
- let cancelled = false;
1648
- (async () => {
1649
- try {
1650
- await provider.ensureReferenceData?.();
1651
- invalidateRelationOptions();
1652
- } catch (error) {
1653
- console.warn("[inventory] Could not prepare reference data", error);
1654
- } finally {
1655
- if (!cancelled) setReferenceDataReady(true);
1656
- }
1657
- })();
1658
- return () => {
1659
- cancelled = true;
1660
- };
1661
- }, [provider]);
1662
- React4.useEffect(() => {
1663
- if (!editId) return;
1664
- let cancelled = false;
1665
- (async () => {
1666
- const p = await provider.getProductById(editId);
1667
- if (cancelled) return;
1668
- setHeaderName(p?.name ?? "");
1669
- setInitialData(p ? flattenProduct(p) : {});
1670
- })();
1671
- return () => {
1672
- cancelled = true;
1673
- };
1674
- }, [editId]);
1675
- const entity = React4.useMemo(
1676
- () => buildProductEntity(t2, productTypes, currency, void 0, provider),
1677
- [t2, productTypes, currency, provider]
1678
- );
1679
- async function save(values) {
1680
- const name = String(values.name ?? "").trim();
1681
- if (!name) {
1682
- toast.error(t2("common.formIncomplete"));
1683
- throw new Error("name required");
1684
- }
1685
- const classification = {
1686
- isIngredient: values.isIngredient === true,
1687
- isForSale: values.isForSale === true,
1688
- isIntermediate: values.isIntermediate === true,
1689
- isAsset: values.isAsset === true,
1690
- isProduced: values.isProduced === true,
1691
- isResale: values.isResale === true,
1692
- trackByUnitLabel: values.trackByUnitLabel === true
1693
- };
1694
- if (!Object.values(classification).some(Boolean)) {
1695
- toast.error(t2("inventory.productForm.classificationRequired"));
1696
- throw new Error("classification required");
1697
- }
1698
- const productType = values.productType ?? productTypes[0]?.value;
1699
- const payload = {
1700
- classification,
1701
- extras: {
1702
- commissionPercent: Number(values.commissionPercent) || 0,
1703
- chartAccountId: values.chartAccountId ?? "",
1704
- contentPerUnit: values.contentPerUnit === "" ? void 0 : Number(values.contentPerUnit) || void 0,
1705
- contentUnitId: values.contentUnitId ?? "",
1706
- avgPurchasePrice: Number(values.avgPurchasePrice) || void 0,
1707
- professionalResalePrice: Number(values.professionalResalePrice) || void 0,
1708
- requireBatchForStock: values.requireBatchForStock === true,
1709
- requireExpiryForStock: values.requireExpiryForStock === true
1710
- },
1711
- ...toFichas(values),
1712
- purchaseUnitId: values.purchaseUnitId ?? "",
1713
- conversionFactor: Number(values.conversionFactor) || 1,
1714
- name,
1715
- sku: values.sku || void 0,
1716
- barcode: values.barcode || void 0,
1717
- brand: values.brand || void 0,
1718
- productType,
1719
- costPrice: Number(values.costPrice) || 0,
1720
- // Switching a menu item back to an input must clear the old commercial
1721
- // price as well as hide it; otherwise reports would keep treating the
1722
- // ingredient as sellable even though the form no longer showed why.
1723
- salePrice: classification.isForSale ? Number(values.salePrice) || void 0 : 0,
1724
- minQuantity: Number(values.minQuantity) || 0,
1725
- maxQuantity: Number(values.maxQuantity) || void 0,
1726
- description: values.description || void 0,
1727
- // Always sent, even when empty: an empty string clears the link, and
1728
- // `undefined` would leave the previous value in place on edit.
1729
- categoryId: values.categoryId ?? "",
1730
- measurementUnitId: values.measurementUnitId ?? "",
1731
- supplierId: values.supplierId ?? "",
1732
- defaultLocationId: values.defaultLocationId ?? ""
1733
- };
1734
- const imageFile = typeof File !== "undefined" && values._imageFile instanceof File ? values._imageFile : void 0;
1735
- if (isEdit && editId) {
1736
- await provider.updateProduct(editId, payload);
1737
- if (imageFile && provider.uploadProductImage) await provider.uploadProductImage(editId, imageFile);
1738
- } else {
1739
- if (await guardProducts() === "blocked") return;
1740
- const created = await createProduct(payload);
1741
- if (imageFile && provider.uploadProductImage) {
1742
- try {
1743
- await provider.uploadProductImage(created.id, imageFile);
1744
- } catch (error) {
1745
- toast.error("Produto criado, mas a foto n\xE3o foi enviada.", {
1746
- description: errorMessage(error)
1747
- });
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)
1748
1795
  }
1749
- }
1750
- invalidateLimit("products");
1751
- }
1752
- onSaved?.();
1753
- }
1754
- const title = isEdit ? headerName || t2("inventory.productForm.editProduct") : t2("inventory.productForm.newProduct");
1755
- const subtitle = isEdit ? void 0 : t2("inventory.productForm.addToCatalog");
1756
- return /* @__PURE__ */ jsxs("div", { className: "space-y-5", children: [
1757
- /* @__PURE__ */ jsx(SubpageHeader, { title, subtitle, onBack: onSaved, parentLabel: t2("inventory.nav.products") }),
1758
- (!initialData || !referenceDataReady) && /* @__PURE__ */ jsx(CrudFormSkeleton, { hideBreadcrumb: true }),
1759
- initialData && referenceDataReady && /* @__PURE__ */ jsx(
1760
- CrudFormPage,
1761
- {
1762
- entityDef: entity,
1763
- mode: isEdit ? "edit" : "create",
1764
- initialData,
1765
- onSubmit: save,
1766
- onCancel: () => onSaved?.(),
1767
- namePlural: t2("inventory.nav.products"),
1768
- hideBreadcrumb: true
1769
- }
1770
- )
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
+ ] })
1771
1826
  ] });
1772
1827
  }
1773
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
+ }
1837
+
1774
1838
  // src/lib/recipe-units.ts
1775
1839
  var FAMILY_BY_ABBR = {
1776
1840
  kg: { family: "mass", toBase: 1e3 },
@@ -2934,13 +2998,14 @@ var SHEET_LAYOUT = `
2934
2998
  .rs-comp{grid-template-columns:minmax(0,1fr) minmax(0,1fr) 9rem auto}
2935
2999
  .rs-comp>*:not(.rs-comp-menu){grid-column:auto}
2936
3000
  .rs-comp>.rs-comp-menu{grid-column:4}
3001
+ .rs-line,.rs-line-head{display:grid;grid-template-columns:minmax(0,2fr) 170px minmax(0,1.2fr) 96px 36px;align-items:center;gap:.5rem;border:0;padding:0}
3002
+ .rs-line>*{grid-column:auto !important}
3003
+ .rs-line>:nth-child(4){justify-content:flex-end}
2937
3004
  }
2938
3005
  @container (min-width: 60rem){
2939
3006
  .rs-head{grid-template-columns:minmax(0,2.2fr) repeat(4,minmax(0,1fr))}
2940
3007
  .rs-head>.rs-name{grid-column:auto}
2941
- .rs-line,.rs-line-head{display:grid;grid-template-columns:minmax(0,2fr) 200px minmax(0,1.4fr) 120px 36px;align-items:center;gap:.75rem;border:0;padding:0}
2942
- .rs-line>*{grid-column:auto !important}
2943
- .rs-line>:nth-child(4){justify-content:flex-end}
3008
+ .rs-line,.rs-line-head{grid-template-columns:minmax(0,2fr) 200px minmax(0,1.4fr) 120px 36px;gap:.75rem}
2944
3009
  .rs-out,.rs-out-head{display:grid;grid-template-columns:minmax(0,2fr) 150px 110px 160px 100px 36px;align-items:center;gap:.75rem;border:0;padding:0}
2945
3010
  }
2946
3011
  `;
@@ -3354,9 +3419,22 @@ function DetailSkeleton() {
3354
3419
  /* @__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)) })
3355
3420
  ] });
3356
3421
  }
3422
+ function productRecordTabs({ entity, flat, hasMenu, hasRecipes, withComplements, initialTab, contributedTabs, t: t2, reload }) {
3423
+ const extra = [];
3424
+ if (hasMenu && flat?.isForSale && withComplements) {
3425
+ extra.push({ id: "complements", label: t2("inventory.complements.tab"), icon: "ListPlus", component: ProductComplementsTab });
3426
+ }
3427
+ if (hasRecipes && (flat?.isProduced || initialTab === "recipe")) {
3428
+ extra.push({ id: "recipe", label: t2("inventory.productRecipe.tab"), icon: "ChefHat", component: ProductRecipeTab, props: { onChanged: reload } });
3429
+ }
3430
+ const tabs = entity.detailTabs ?? [];
3431
+ const stockAt = tabs.findIndex((tab) => tab.id === "stock");
3432
+ const own = stockAt < 0 ? [...tabs, ...extra] : [...tabs.slice(0, stockAt), ...extra, ...tabs.slice(stockAt)];
3433
+ return [...own, ...contributedTabs];
3434
+ }
3357
3435
  function ProductDetailView({ productId, initialTab, onBack, onEdit }) {
3358
3436
  const t2 = useTranslation();
3359
- const { currency, productTypes } = useInventoryConfig();
3437
+ const { currency, productTypes, productDetailTabs: contributedTabs } = useInventoryConfig();
3360
3438
  const provider = useInventoryProvider();
3361
3439
  const [product, setProduct] = useState(null);
3362
3440
  const [loading, setLoading] = useState(true);
@@ -3374,9 +3452,11 @@ function ProductDetailView({ productId, initialTab, onBack, onEdit }) {
3374
3452
  active = false;
3375
3453
  };
3376
3454
  }, [productId, provider, reloadKey]);
3455
+ const hasMenu = useHasMenu();
3456
+ const hasRecipes = useHasRecipes();
3377
3457
  const entity = useMemo(
3378
- () => buildProductEntity(t2, productTypes, currency, reload),
3379
- [t2, productTypes, currency, reload]
3458
+ () => buildProductEntity(t2, productTypes, currency, reload, void 0, true, hasMenu, hasRecipes),
3459
+ [t2, productTypes, currency, reload, hasMenu, hasRecipes]
3380
3460
  );
3381
3461
  const flat = useMemo(
3382
3462
  () => product ? flattenProduct(product) : null,
@@ -3388,16 +3468,7 @@ function ProductDetailView({ productId, initialTab, onBack, onEdit }) {
3388
3468
  }, [provider, productId, reload, flat]);
3389
3469
  const [photoOpen, setPhotoOpen] = useState(false);
3390
3470
  const entityForDetail = useMemo(() => {
3391
- const extra = [];
3392
- if (flat?.isForSale && provider.getProductOptionGroups) {
3393
- extra.push({ id: "complements", label: t2("inventory.complements.tab"), icon: "ListPlus", component: ProductComplementsTab });
3394
- }
3395
- if (flat?.isProduced || initialTab === "recipe") {
3396
- extra.push({ id: "recipe", label: t2("inventory.productRecipe.tab"), icon: "ChefHat", component: ProductRecipeTab, props: { onChanged: reload } });
3397
- }
3398
- const tabs = entity.detailTabs ?? [];
3399
- const stockAt = tabs.findIndex((tab) => tab.id === "stock");
3400
- const detailTabs = stockAt < 0 ? [...tabs, ...extra] : [...tabs.slice(0, stockAt), ...extra, ...tabs.slice(stockAt)];
3471
+ const detailTabs = productRecordTabs({ entity, flat, hasMenu, hasRecipes, withComplements: Boolean(provider.getProductOptionGroups), initialTab, contributedTabs, t: t2, reload });
3401
3472
  return {
3402
3473
  ...entity,
3403
3474
  detailTabs,
@@ -3408,7 +3479,7 @@ function ProductDetailView({ productId, initialTab, onBack, onEdit }) {
3408
3479
  } }] : []
3409
3480
  ]
3410
3481
  };
3411
- }, [entity, flat?.isForSale, flat?.isProduced, initialTab, provider, reload, t2]);
3482
+ }, [entity, hasMenu, hasRecipes, flat?.isForSale, flat?.isProduced, initialTab, provider, reload, t2, contributedTabs]);
3412
3483
  if (loading) {
3413
3484
  return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
3414
3485
  /* @__PURE__ */ jsx(SubpageHeader, { title: t2("inventory.nav.products"), onBack }),
@@ -3443,6 +3514,154 @@ function ProductDetailView({ productId, initialTab, onBack, onEdit }) {
3443
3514
  photoOpen && /* @__PURE__ */ jsx(GenerateImageDialog, { product, onClose: () => setPhotoOpen(false), onApplied: reload })
3444
3515
  ] });
3445
3516
  }
3517
+ function ProductCrudForm({ editId, onSaved, onOpenTab }) {
3518
+ const t2 = useTranslation();
3519
+ const { productTypes, currency, productDetailTabs: contributedTabs } = useInventoryConfig();
3520
+ const provider = useInventoryProvider();
3521
+ const createProduct = useInventoryStore((s) => s.createProduct);
3522
+ const guardProducts = useLimitGuard("products");
3523
+ const isEdit = !!editId;
3524
+ const [initialData, setInitialData] = React14.useState(isEdit ? null : {});
3525
+ const [headerName, setHeaderName] = React14.useState("");
3526
+ const [referenceDataReady, setReferenceDataReady] = React14.useState(false);
3527
+ React14.useEffect(() => {
3528
+ let cancelled = false;
3529
+ (async () => {
3530
+ try {
3531
+ await provider.ensureReferenceData?.();
3532
+ invalidateRelationOptions();
3533
+ } catch (error) {
3534
+ console.warn("[inventory] Could not prepare reference data", error);
3535
+ } finally {
3536
+ if (!cancelled) setReferenceDataReady(true);
3537
+ }
3538
+ })();
3539
+ return () => {
3540
+ cancelled = true;
3541
+ };
3542
+ }, [provider]);
3543
+ React14.useEffect(() => {
3544
+ if (!editId) return;
3545
+ let cancelled = false;
3546
+ (async () => {
3547
+ const p = await provider.getProductById(editId);
3548
+ if (cancelled) return;
3549
+ setHeaderName(p?.name ?? "");
3550
+ setInitialData(p ? flattenProduct(p) : {});
3551
+ })();
3552
+ return () => {
3553
+ cancelled = true;
3554
+ };
3555
+ }, [editId]);
3556
+ const hasMenu = useHasMenu();
3557
+ const hasRecipes = useHasRecipes();
3558
+ const entity = React14.useMemo(
3559
+ () => buildProductEntity(t2, productTypes, currency, void 0, provider, true, hasMenu, hasRecipes),
3560
+ [t2, productTypes, currency, provider, hasMenu, hasRecipes]
3561
+ );
3562
+ async function save(values) {
3563
+ const name = String(values.name ?? "").trim();
3564
+ if (!name) {
3565
+ toast.error(t2("common.formIncomplete"));
3566
+ throw new Error("name required");
3567
+ }
3568
+ const classification = {
3569
+ isIngredient: values.isIngredient === true,
3570
+ isForSale: values.isForSale === true,
3571
+ isIntermediate: values.isIntermediate === true,
3572
+ isAsset: values.isAsset === true,
3573
+ isProduced: values.isProduced === true,
3574
+ isResale: values.isResale === true,
3575
+ trackByUnitLabel: values.trackByUnitLabel === true
3576
+ };
3577
+ if (!Object.values(classification).some(Boolean)) {
3578
+ toast.error(t2("inventory.productForm.classificationRequired"));
3579
+ throw new Error("classification required");
3580
+ }
3581
+ const productType = values.productType ?? productTypes[0]?.value;
3582
+ const payload = {
3583
+ classification,
3584
+ extras: {
3585
+ commissionPercent: Number(values.commissionPercent) || 0,
3586
+ chartAccountId: values.chartAccountId ?? "",
3587
+ contentPerUnit: values.contentPerUnit === "" ? void 0 : Number(values.contentPerUnit) || void 0,
3588
+ contentUnitId: values.contentUnitId ?? "",
3589
+ avgPurchasePrice: Number(values.avgPurchasePrice) || void 0,
3590
+ professionalResalePrice: Number(values.professionalResalePrice) || void 0,
3591
+ requireBatchForStock: values.requireBatchForStock === true,
3592
+ requireExpiryForStock: values.requireExpiryForStock === true
3593
+ },
3594
+ ...toFichas(values),
3595
+ purchaseUnitId: values.purchaseUnitId ?? "",
3596
+ conversionFactor: Number(values.conversionFactor) || 1,
3597
+ name,
3598
+ sku: values.sku || void 0,
3599
+ barcode: values.barcode || void 0,
3600
+ brand: values.brand || void 0,
3601
+ productType,
3602
+ costPrice: Number(values.costPrice) || 0,
3603
+ // Switching a menu item back to an input must clear the old commercial
3604
+ // price as well as hide it; otherwise reports would keep treating the
3605
+ // ingredient as sellable even though the form no longer showed why.
3606
+ salePrice: classification.isForSale ? Number(values.salePrice) || void 0 : 0,
3607
+ minQuantity: Number(values.minQuantity) || 0,
3608
+ maxQuantity: Number(values.maxQuantity) || void 0,
3609
+ description: values.description || void 0,
3610
+ // Always sent, even when empty: an empty string clears the link, and
3611
+ // `undefined` would leave the previous value in place on edit.
3612
+ categoryId: values.categoryId ?? "",
3613
+ measurementUnitId: values.measurementUnitId ?? "",
3614
+ supplierId: values.supplierId ?? "",
3615
+ defaultLocationId: values.defaultLocationId ?? ""
3616
+ };
3617
+ const imageFile = typeof File !== "undefined" && values._imageFile instanceof File ? values._imageFile : void 0;
3618
+ if (isEdit && editId) {
3619
+ await provider.updateProduct(editId, payload);
3620
+ if (imageFile && provider.uploadProductImage) await provider.uploadProductImage(editId, imageFile);
3621
+ } else {
3622
+ if (await guardProducts() === "blocked") return;
3623
+ const created = await createProduct(payload);
3624
+ if (imageFile && provider.uploadProductImage) {
3625
+ try {
3626
+ await provider.uploadProductImage(created.id, imageFile);
3627
+ } catch (error) {
3628
+ toast.error("Produto criado, mas a foto n\xE3o foi enviada.", {
3629
+ description: errorMessage(error)
3630
+ });
3631
+ }
3632
+ }
3633
+ invalidateLimit("products");
3634
+ }
3635
+ onSaved?.();
3636
+ }
3637
+ const formEntity = React14.useMemo(() => {
3638
+ if (!isEdit || !initialData) return entity;
3639
+ return {
3640
+ ...entity,
3641
+ detailTabs: productRecordTabs({ entity, flat: initialData, hasMenu, hasRecipes, withComplements: Boolean(provider.getProductOptionGroups), contributedTabs, t: t2 })
3642
+ };
3643
+ }, [entity, isEdit, initialData, hasMenu, hasRecipes, provider, contributedTabs, t2]);
3644
+ const ready = Boolean(initialData) && referenceDataReady;
3645
+ const title = isEdit ? headerName || t2("inventory.productForm.editProduct") : t2("inventory.productForm.newProduct");
3646
+ const subtitle = isEdit ? void 0 : t2("inventory.productForm.addToCatalog");
3647
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-5", children: [
3648
+ (!isEdit || !ready) && /* @__PURE__ */ jsx(SubpageHeader, { title, subtitle, onBack: onSaved, parentLabel: t2("inventory.nav.products") }),
3649
+ !ready && /* @__PURE__ */ jsx(CrudFormSkeleton, { twoColumn: true, tabs: isEdit }),
3650
+ ready && /* @__PURE__ */ jsx(
3651
+ CrudFormPage,
3652
+ {
3653
+ entityDef: formEntity,
3654
+ mode: isEdit ? "edit" : "create",
3655
+ initialData,
3656
+ onSubmit: save,
3657
+ onCancel: () => onSaved?.(),
3658
+ namePlural: t2("inventory.nav.products"),
3659
+ hideBreadcrumb: !isEdit,
3660
+ onRecordTab: isEdit ? onOpenTab : void 0
3661
+ }
3662
+ )
3663
+ ] });
3664
+ }
3446
3665
  function signedQuantity(q) {
3447
3666
  const n = Number(q) || 0;
3448
3667
  return n > 0 ? `+${n}` : String(n);
@@ -5632,7 +5851,7 @@ function parseQuantity(raw) {
5632
5851
  if (!Number.isFinite(parsed) || parsed < 0) return void 0;
5633
5852
  return parsed;
5634
5853
  }
5635
- var CountedField = React4.memo(function CountedField2({
5854
+ var CountedField = React14.memo(function CountedField2({
5636
5855
  entry,
5637
5856
  value,
5638
5857
  onChange,
@@ -6217,47 +6436,6 @@ function BulkProductEditView() {
6217
6436
  }
6218
6437
  );
6219
6438
  }
6220
- var INVENTORY_MODULE_DEFAULTS = {
6221
- recipes: true
6222
- };
6223
- var INVENTORY_DEFAULTS = {
6224
- lowStockAlerts: true,
6225
- requireReason: true,
6226
- autoDeduct: false,
6227
- requireSku: false,
6228
- allowNegative: false,
6229
- lowStockEmail: false,
6230
- expiryWarnings: true
6231
- };
6232
- function InventoryGeneralSettings({ config }) {
6233
- const t2 = useTranslation();
6234
- const s = useTenantPluginSettings("inventory", INVENTORY_DEFAULTS);
6235
- const mods = useTenantPluginSettings("inventory", INVENTORY_MODULE_DEFAULTS);
6236
- return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
6237
- config?.modules.recipes && /* @__PURE__ */ jsx(SettingsGroup, { title: t2("inventory.settings.modules"), description: t2("inventory.settings.modulesDesc"), children: /* @__PURE__ */ jsx(
6238
- ToggleRow,
6239
- {
6240
- label: t2("inventory.settings.recipes"),
6241
- description: t2("inventory.settings.recipesDesc"),
6242
- checked: mods.get("recipes"),
6243
- onChange: (v) => mods.set("recipes", v)
6244
- }
6245
- ) }),
6246
- /* @__PURE__ */ jsxs(SettingsGroup, { title: t2("inventory.settings.stockManagement"), description: t2("inventory.settings.stockManagementDesc"), children: [
6247
- /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.lowStockAlerts"), description: t2("inventory.settings.lowStockAlertsDesc"), checked: s.get("lowStockAlerts"), onChange: (v) => s.set("lowStockAlerts", v) }),
6248
- /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.requireReason"), description: t2("inventory.settings.requireReasonDesc"), checked: s.get("requireReason"), onChange: (v) => s.set("requireReason", v) }),
6249
- /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.autoDeduct"), description: t2("inventory.settings.autoDeductDesc"), checked: s.get("autoDeduct"), onChange: (v) => s.set("autoDeduct", v) })
6250
- ] }),
6251
- /* @__PURE__ */ jsxs(SettingsGroup, { title: t2("inventory.settings.products"), description: t2("inventory.settings.productsDesc"), children: [
6252
- /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.requireSku"), description: t2("inventory.settings.requireSkuDesc"), checked: s.get("requireSku"), onChange: (v) => s.set("requireSku", v) }),
6253
- /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.allowNegative"), description: t2("inventory.settings.allowNegativeDesc"), checked: s.get("allowNegative"), onChange: (v) => s.set("allowNegative", v) })
6254
- ] }),
6255
- /* @__PURE__ */ jsxs(SettingsGroup, { title: t2("inventory.settings.notifications"), description: t2("inventory.settings.notificationsDesc"), children: [
6256
- /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.lowStockEmail"), description: t2("inventory.settings.lowStockEmailDesc"), checked: s.get("lowStockEmail"), onChange: (v) => s.set("lowStockEmail", v) }),
6257
- /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.expiryWarnings"), description: t2("inventory.settings.expiryWarningsDesc"), checked: s.get("expiryWarnings"), onChange: (v) => s.set("expiryWarnings", v) })
6258
- ] })
6259
- ] });
6260
- }
6261
6439
  var STEPS = [
6262
6440
  { id: "welcome", icon: Package, titleKey: "inventory.onboarding.welcome", descKey: "inventory.onboarding.description" },
6263
6441
  { id: "units", icon: Ruler, titleKey: "inventory.onboarding.units.title", descKey: "inventory.onboarding.units.description" },
@@ -6307,7 +6485,7 @@ function InventoryOnboarding({ onComplete }) {
6307
6485
  ] })
6308
6486
  ] }) });
6309
6487
  }
6310
- function buildNav(config, view, navigate, t2) {
6488
+ function buildNav(config, view, navigate, t2, house) {
6311
6489
  const items = [
6312
6490
  // Tudo o que se faz COM o catálogo mora aqui: a lista, como ela se agrupa
6313
6491
  // (Categorias) e como se mexe em muitos de uma vez. A primeira filha
@@ -6325,7 +6503,7 @@ function buildNav(config, view, navigate, t2) {
6325
6503
  active: view.startsWith("products") || view === "product-categories" || view === "bulk-products",
6326
6504
  children: [
6327
6505
  { id: "products-new", kind: "action", label: t2("inventory.nav.new"), active: view === "products-new", onClick: () => navigate("products-new") },
6328
- { 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") },
6506
+ { 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") },
6329
6507
  { id: "product-categories", label: t2("registry.product-categories"), active: view === "product-categories", onClick: () => navigate("product-categories") },
6330
6508
  { id: "bulk-products", label: "Altera\xE7\xE3o em massa", active: view === "bulk-products", onClick: () => navigate("bulk-products") }
6331
6509
  ]
@@ -6374,7 +6552,7 @@ function buildNav(config, view, navigate, t2) {
6374
6552
  // panela rende.
6375
6553
  { id: "transfers", label: "Transfer\xEAncias", icon: "ArrowLeftRight", active: view === "transfers", onClick: () => navigate("transfers") },
6376
6554
  { id: "stock-indicators", label: "Indicadores", icon: "Target", active: view === "stock-indicators", onClick: () => navigate("stock-indicators") },
6377
- { id: "requisitions", label: "Requisi\xE7\xF5es", icon: "ClipboardList", active: view === "requisitions", onClick: () => navigate("requisitions") },
6555
+ ...house.requisitions ? [{ id: "requisitions", label: "Requisi\xE7\xF5es", icon: "ClipboardList", active: view === "requisitions", onClick: () => navigate("requisitions") }] : [],
6378
6556
  // Fichas Técnicas sits here, after Requisições, as in V1 (pushed below
6379
6557
  // only when the house uses recipes).
6380
6558
  ...config.modules.recipes ? [{
@@ -6392,13 +6570,13 @@ function buildNav(config, view, navigate, t2) {
6392
6570
  //
6393
6571
  // "Nova contagem" não está na navegação: era uma AÇÃO dentro de um menu, e
6394
6572
  // a lista de contagens já tem o "+" no cabeçalho dela.
6395
- {
6573
+ ...house.usageAudit ? [{
6396
6574
  id: "stock-usage-audit",
6397
6575
  label: t2("inventory.nav.usageAudit"),
6398
6576
  icon: "ScanSearch",
6399
6577
  active: view === "stock-usage-audit",
6400
6578
  onClick: () => navigate("stock-usage-audit")
6401
- },
6579
+ }] : [],
6402
6580
  // Produção fica de fora por decisão do founder: são seis telas e um assunto
6403
6581
  // próprio, não um buraco do estoque.
6404
6582
  //
@@ -6413,11 +6591,12 @@ function buildNav(config, view, navigate, t2) {
6413
6591
  active: ["stock-locations", "temperature"].some((v) => view === v),
6414
6592
  children: [
6415
6593
  { id: "stock-locations", label: "Locais", defaultChild: true, active: view === "stock-locations", onClick: () => navigate("stock-locations") },
6416
- { id: "temperature", label: "Temperatura", active: view === "temperature", onClick: () => navigate("temperature") }
6594
+ ...house.temperature ? [{ id: "temperature", label: "Temperatura", active: view === "temperature", onClick: () => navigate("temperature") }] : []
6417
6595
  ]
6418
6596
  }
6419
6597
  ];
6420
6598
  for (const extra of config.extraViews) {
6599
+ if (house.hiddenViews.includes(extra.id)) continue;
6421
6600
  const own = [extra.id, ...(extra.children ?? []).map((c) => c.id)];
6422
6601
  const hits = (id) => view === id || view.startsWith(`${id}/`) || view.startsWith(`${id}:`);
6423
6602
  items.push({
@@ -6441,7 +6620,7 @@ function buildNav(config, view, navigate, t2) {
6441
6620
  }
6442
6621
  function InventoryPage({ config: appConfig, provider, store, registries }) {
6443
6622
  const t2 = useTranslation();
6444
- const houseModules = useTenantPluginSettings("inventory", INVENTORY_MODULE_DEFAULTS);
6623
+ const houseModules = useInventoryHouseModules();
6445
6624
  const recipesOn = houseModules.get("recipes");
6446
6625
  const config = useMemo(
6447
6626
  () => appConfig.modules.recipes && recipesOn ? appConfig : { ...appConfig, modules: { ...appConfig.modules, recipes: false } },
@@ -6462,15 +6641,20 @@ function InventoryPage({ config: appConfig, provider, store, registries }) {
6462
6641
  "products-recipe": 1,
6463
6642
  settings: 1
6464
6643
  }, "dashboard");
6465
- const checkExistingData = React4.useCallback(
6644
+ const checkExistingData = React14.useCallback(
6466
6645
  async () => (await provider.getProducts({ pageSize: 1 })).total > 0,
6467
6646
  [provider]
6468
6647
  );
6469
6648
  const onboarding = usePluginOnboardingGate("saas-core:inventory-onboarded", checkExistingData);
6470
6649
  const isSettings = view === "settings";
6471
6650
  const isSummary = view === "dashboard" || view === "";
6472
- const nav = buildNav(config, view, navigate, t2);
6473
- const completeOnboarding = React4.useCallback(async () => {
6651
+ const nav = buildNav(config, view, navigate, t2, {
6652
+ requisitions: houseModules.get("requisitions"),
6653
+ usageAudit: houseModules.get("usageAudit"),
6654
+ temperature: houseModules.get("temperature"),
6655
+ hiddenViews: houseModules.get("hiddenViews")
6656
+ });
6657
+ const completeOnboarding = React14.useCallback(async () => {
6474
6658
  await provider.ensureReferenceData?.();
6475
6659
  onboarding.complete();
6476
6660
  }, [provider, onboarding.complete]);
@@ -6533,11 +6717,16 @@ function InventoryPage({ config: appConfig, provider, store, registries }) {
6533
6717
  { id: "products", render: () => /* @__PURE__ */ jsx(ProductListView, { onNew: () => navigate("products-new"), onOpen: (id) => navigate(`products-detail:${id}`) }) },
6534
6718
  { id: "products-list", render: () => /* @__PURE__ */ jsx(ProductListView, { onNew: () => navigate("products-new"), onOpen: (id) => navigate(`products-detail:${id}`) }) },
6535
6719
  { id: "products-new", render: () => /* @__PURE__ */ jsx(ProductCrudForm, { onSaved: () => navigate("products-list") }) },
6536
- { id: "products-edit", render: ({ id }) => /* @__PURE__ */ jsx(ProductCrudForm, { editId: id, onSaved: () => navigate(`products-detail:${id}`) }) },
6720
+ { id: "products-edit", render: ({ id }) => /* @__PURE__ */ jsx(ProductCrudForm, { editId: id, onSaved: () => navigate(`products-detail:${id}`), onOpenTab: (tab) => navigate(`products-${tab}:${id}`) }) },
6537
6721
  { id: "products-detail", render: ({ id }) => /* @__PURE__ */ jsx(ProductDetailView, { productId: id, onBack: () => navigate("products-list"), onEdit: () => navigate(`products-edit:${id}`) }) },
6538
- // The tab writes `/products/<id>/stock` into the URL, so that link has to
6539
- // come back to the same page with the tab open.
6540
- { id: "products-stock", render: ({ id }) => /* @__PURE__ */ jsx(ProductDetailView, { productId: id, initialTab: "stock", onBack: () => navigate("products-list"), onEdit: () => navigate(`products-edit:${id}`) }) }
6722
+ // Every tab writes `/products/<id>/<tab>` into the URL, so each of those
6723
+ // links has to come back to the same page with that tab open. Only
6724
+ // `stock` had a route: reloading on any other tab fell through to the
6725
+ // module's first screen.
6726
+ ...["stock", "variants", "barcodes", "complements", ...config.productDetailTabs.map((tab) => tab.id)].map((tab) => ({
6727
+ id: `products-${tab}`,
6728
+ render: ({ id }) => /* @__PURE__ */ jsx(ProductDetailView, { productId: id, initialTab: tab, onBack: () => navigate("products-list"), onEdit: () => navigate(`products-edit:${id}`) })
6729
+ }))
6541
6730
  ] : [],
6542
6731
  { id: "stock-entry", render: () => /* @__PURE__ */ jsx(StockMovementView, { defaultType: "entry", onSaved: () => navigate("stock-history") }) },
6543
6732
  { id: "stock-exit", render: () => /* @__PURE__ */ jsx(StockMovementView, { defaultType: "exit", onSaved: () => navigate("stock-history") }) },
@@ -6577,14 +6766,19 @@ function InventoryPage({ config: appConfig, provider, store, registries }) {
6577
6766
  // topo e largá-lo numa fileira própria, encostado à direita do corpo. Com
6578
6767
  // isso, Locais e Categorias eram as duas únicas listas do Estoque cujo botão
6579
6768
  // de criar ficava em outro lugar que o de Produtos e o de Contagens.
6580
- { id: "product-categories", render: () => {
6581
- const reg = registries?.find((r) => r.id === "product-categories");
6582
- 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"] });
6583
- } },
6584
- { id: "stock-locations", render: () => {
6585
- const reg = registries?.find((r) => r.id === "stock-locations");
6586
- return reg ? /* @__PURE__ */ jsx(RegistryCrudView, { registry: reg, basePath: "/inventory/stock-locations" }) : /* @__PURE__ */ jsx(StockLocationsView, {});
6587
- } },
6769
+ // The module writes a view id as a path (`product-categories` is
6770
+ // `/product/categories`), so the registry's base path and its own
6771
+ // new/edit addresses have to be spelled the same way or "New" lands on 404.
6772
+ ...["", "-new", "-edit", "-detail"].flatMap((suffix) => [
6773
+ { id: `product-categories${suffix}`, render: () => {
6774
+ const reg = registries?.find((r) => r.id === "product-categories");
6775
+ 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"] });
6776
+ } },
6777
+ { id: `stock-locations${suffix}`, render: () => {
6778
+ const reg = registries?.find((r) => r.id === "stock-locations");
6779
+ return reg ? /* @__PURE__ */ jsx(RegistryCrudView, { registry: reg, basePath: "/inventory/stock/locations" }) : /* @__PURE__ */ jsx(StockLocationsView, {});
6780
+ } }
6781
+ ]),
6588
6782
  { id: "temperature", render: () => /* @__PURE__ */ jsx(TemperatureView, {}) },
6589
6783
  { id: "bulk-products", render: () => /* @__PURE__ */ jsx(BulkProductEditView, {}) },
6590
6784
  { id: "stock-indicators", render: () => /* @__PURE__ */ jsx(StockIndicatorsView, {}) },
@@ -8406,6 +8600,42 @@ function createSupabaseInventoryProvider() {
8406
8600
  });
8407
8601
  if (error) throw new Error(error.message);
8408
8602
  },
8603
+ async getProductVariants(productId) {
8604
+ const { core } = getClients();
8605
+ 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 });
8606
+ const rows = data ?? [];
8607
+ if (rows.length === 0) return [];
8608
+ const balances = await core.from("stock_balances").select("variant_id, on_hand").in("variant_id", rows.map((r) => r.id));
8609
+ const onHand = /* @__PURE__ */ new Map();
8610
+ for (const b of balances.data ?? []) {
8611
+ onHand.set(b.variant_id, (onHand.get(b.variant_id) ?? 0) + (Number(b.on_hand) || 0));
8612
+ }
8613
+ return rows.map((r) => ({
8614
+ id: r.id,
8615
+ name: r.name,
8616
+ sku: r.sku ?? void 0,
8617
+ isDefault: !!r.is_default,
8618
+ isActive: r.is_active !== false,
8619
+ onHand: onHand.get(r.id) ?? 0
8620
+ }));
8621
+ },
8622
+ async addProductVariant(productId, input) {
8623
+ const { core } = getClients();
8624
+ const existing = await provider.getProductVariants(productId);
8625
+ const { error } = await core.from("product_variants").insert({
8626
+ tenant_id: getTenantId(),
8627
+ product_id: productId,
8628
+ name: input.name.trim(),
8629
+ sku: input.sku?.trim() || null,
8630
+ sort_order: existing.length
8631
+ });
8632
+ if (error) throw new Error(error.message);
8633
+ },
8634
+ async setProductVariantActive(id, active) {
8635
+ const { core } = getClients();
8636
+ const { error } = await core.from("product_variants").update({ is_active: active }).eq("id", id);
8637
+ if (error) throw new Error(error.message);
8638
+ },
8409
8639
  async removeProductBarcode(id) {
8410
8640
  const { core } = getClients();
8411
8641
  const { error } = await core.from(T.productBarcodes).delete().eq("id", id);
@@ -9755,6 +9985,9 @@ var supplierEntity = {
9755
9985
  // office that invoices, the warehouse the goods leave from). `layout:
9756
9986
  // 'person'` gives the record an "Addresses" tab backed by public.addresses,
9757
9987
  // which is the same relation clients and patients already use.
9988
+ // Declares the supplier as someone the house pays, which is what keeps
9989
+ // the "Bank accounts" section on the record.
9990
+ { key: "payeeAccount", label: "Bank account", type: "bank", showInTable: false, showInDetail: false },
9758
9991
  { key: "notes", label: "Notes", type: "textarea" },
9759
9992
  { key: "isActive", label: "Active", type: "boolean", showInTable: true, defaultValue: true }
9760
9993
  ],
@@ -10396,6 +10629,11 @@ var en = {
10396
10629
  "inventory.settings.modulesDesc": "What this house shows inside Inventory",
10397
10630
  "inventory.settings.recipes": "Recipes",
10398
10631
  "inventory.settings.recipesDesc": "The bill of materials that says what each product consumes",
10632
+ "inventory.settings.requisitions": "Requisitions",
10633
+ "inventory.settings.requisitionsDesc": "Internal requests for material between areas",
10634
+ "inventory.settings.usageAuditDesc": "What left stock against what was sold",
10635
+ "inventory.settings.temperature": "Temperature",
10636
+ "inventory.settings.temperatureDesc": "Temperature log for refrigerated locations",
10399
10637
  "inventory.settings.stockManagement": "Stock Management",
10400
10638
  "inventory.settings.stockManagementDesc": "How stock levels are tracked",
10401
10639
  "inventory.settings.lowStockAlerts": "Low stock alerts",
@@ -10518,6 +10756,18 @@ var en = {
10518
10756
  "inventory.productType.sale": "For sale",
10519
10757
  "inventory.productType.intermediate": "Intermediate",
10520
10758
  "inventory.productType.asset": "Asset",
10759
+ "inventory.variants.tab": "Variants",
10760
+ "inventory.variants.name": "Variant",
10761
+ "inventory.variants.namePlaceholder": "E.g. M, L, XL, Blue",
10762
+ "inventory.variants.sku": "SKU",
10763
+ "inventory.variants.skuPlaceholder": "Optional",
10764
+ "inventory.variants.default": "Default",
10765
+ "inventory.variants.empty": "No variants. The product is sold and stocked as a single item.",
10766
+ "inventory.variants.duplicate": "A variant with this name already exists.",
10767
+ "inventory.variants.loadFailed": "Could not load the variants.",
10768
+ "inventory.variants.saveFailed": "Could not save the variant.",
10769
+ "inventory.variants.unavailable": "Variants are not available in this workspace.",
10770
+ "inventory.variants.onHand": "{{count}} in stock",
10521
10771
  "inventory.barcodes.tab": "Barcodes",
10522
10772
  "inventory.barcodes.code": "Barcode",
10523
10773
  "inventory.barcodes.codePlaceholder": "Type or scan",
@@ -11233,6 +11483,11 @@ var ptBR = {
11233
11483
  "inventory.settings.modulesDesc": "O que desta casa aparece no Estoque",
11234
11484
  "inventory.settings.recipes": "Receitas",
11235
11485
  "inventory.settings.recipesDesc": "A ficha t\xE9cnica que diz quanto cada produto consome",
11486
+ "inventory.settings.requisitions": "Requisi\xE7\xF5es",
11487
+ "inventory.settings.requisitionsDesc": "Pedidos internos de material entre setores",
11488
+ "inventory.settings.usageAuditDesc": "O que saiu do estoque contra o que foi vendido",
11489
+ "inventory.settings.temperature": "Temperatura",
11490
+ "inventory.settings.temperatureDesc": "Registro de temperatura dos locais refrigerados",
11236
11491
  "inventory.settings.stockManagement": "Gest\xE3o de Estoque",
11237
11492
  "inventory.settings.stockManagementDesc": "Como os n\xEDveis de estoque s\xE3o rastreados",
11238
11493
  "inventory.settings.lowStockAlerts": "Alertas de estoque baixo",
@@ -11355,6 +11610,18 @@ var ptBR = {
11355
11610
  "inventory.productType.sale": "Venda",
11356
11611
  "inventory.productType.intermediate": "Intermedi\xE1rio",
11357
11612
  "inventory.productType.asset": "Patrim\xF4nio",
11613
+ "inventory.variants.tab": "Varia\xE7\xF5es",
11614
+ "inventory.variants.name": "Varia\xE7\xE3o",
11615
+ "inventory.variants.namePlaceholder": "Ex.: M, G, GG, Azul",
11616
+ "inventory.variants.sku": "SKU",
11617
+ "inventory.variants.skuPlaceholder": "Opcional",
11618
+ "inventory.variants.default": "Padr\xE3o",
11619
+ "inventory.variants.empty": "Nenhuma varia\xE7\xE3o. O produto \xE9 vendido e estocado como um item s\xF3.",
11620
+ "inventory.variants.duplicate": "J\xE1 existe uma varia\xE7\xE3o com esse nome.",
11621
+ "inventory.variants.loadFailed": "N\xE3o foi poss\xEDvel carregar as varia\xE7\xF5es.",
11622
+ "inventory.variants.saveFailed": "N\xE3o foi poss\xEDvel salvar a varia\xE7\xE3o.",
11623
+ "inventory.variants.unavailable": "Varia\xE7\xF5es n\xE3o est\xE3o dispon\xEDveis nesta conta.",
11624
+ "inventory.variants.onHand": "{{count}} em estoque",
11358
11625
  "inventory.barcodes.tab": "C\xF3digos de Barras",
11359
11626
  "inventory.barcodes.code": "C\xF3digo de barras",
11360
11627
  "inventory.barcodes.codePlaceholder": "Digite ou bipe",
@@ -20874,6 +21141,261 @@ $$;
20874
21141
  REVOKE ALL ON FUNCTION public.inventory_save_recipe(jsonb) FROM PUBLIC, anon;
20875
21142
  GRANT EXECUTE ON FUNCTION public.inventory_save_recipe(jsonb) TO authenticated;
20876
21143
  `;
21144
+ var MIGRATION_026_STOCK_COST_KNOWS_ITS_CURRENCY = `-- ============================================================================
21145
+ -- 026 \u2014 stock cost knows its currency
21146
+ --
21147
+ -- \`unit_cost\` was a bare number. With a shop in Rio and one in Barcelona, and
21148
+ -- shirts paid for in dollars, three currencies would have met in one weighted
21149
+ -- average.
21150
+ --
21151
+ -- The rule: the cost a position carries is ALWAYS in the currency of the unit
21152
+ -- that holds the stock, so every sum and average that already exists stays
21153
+ -- right. What was actually paid, when it was paid in something else, is kept
21154
+ -- beside it (\`source_currency\`, \`source_unit_cost\`, \`fx_rate\`) and converted
21155
+ -- once, on the way in.
21156
+ --
21157
+ -- A receipt says the currency on the document (or the line):
21158
+ -- inventory_receive(loc, lines, '{"currency":"USD","fx_rate":5.42}')
21159
+ -- With no rate given, the tenant's rate for the day is used; with none on
21160
+ -- file, the receipt is refused rather than booked at a made-up cost.
21161
+ --
21162
+ -- Moving stock between units of different currencies is refused for now: the
21163
+ -- same cost number would land in the other currency unconverted.
21164
+ -- ============================================================================
21165
+
21166
+ DO $$
21167
+ BEGIN
21168
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
21169
+ WHERE table_schema = 'public' AND table_name = 'plg_inventory_stock_movements' AND column_name = 'currency') THEN
21170
+ ALTER TABLE public.plg_inventory_stock_movements ADD COLUMN currency text NOT NULL DEFAULT 'BRL';
21171
+ ALTER TABLE public.plg_inventory_stock_movements ALTER COLUMN currency DROP DEFAULT;
21172
+ END IF;
21173
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
21174
+ WHERE table_schema = 'public' AND table_name = 'plg_inventory_stock_positions' AND column_name = 'currency') THEN
21175
+ ALTER TABLE public.plg_inventory_stock_positions ADD COLUMN currency text NOT NULL DEFAULT 'BRL';
21176
+ ALTER TABLE public.plg_inventory_stock_positions ALTER COLUMN currency DROP DEFAULT;
21177
+ END IF;
21178
+ END $$;
21179
+ ALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS source_currency text;
21180
+ ALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS source_unit_cost numeric;
21181
+ ALTER TABLE public.plg_inventory_stock_movements ADD COLUMN IF NOT EXISTS fx_rate numeric;
21182
+
21183
+ COMMENT ON COLUMN public.plg_inventory_stock_movements.currency IS 'Currency of unit_cost/total_cost: the unit that holds the stock.';
21184
+ COMMENT ON COLUMN public.plg_inventory_stock_movements.source_currency IS 'Currency the goods were paid in, when not the unit''s.';
21185
+ COMMENT ON COLUMN public.plg_inventory_stock_movements.source_unit_cost IS 'Unit cost as paid, in source_currency.';
21186
+ 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.';
21187
+ COMMENT ON COLUMN public.plg_inventory_stock_positions.currency IS 'Currency of unit_cost: the unit that holds this balance.';
21188
+
21189
+ CREATE OR REPLACE FUNCTION app.inventory_movements_currency()
21190
+ RETURNS trigger
21191
+ LANGUAGE plpgsql
21192
+ SECURITY DEFINER
21193
+ SET search_path TO ''
21194
+ AS $function$
21195
+ DECLARE
21196
+ v_unit_cur text := app.unit_currency(NEW.tenant_id, NEW.unit_id);
21197
+ v_dst_unit uuid;
21198
+ v_src text := nullif(upper(btrim(coalesce(NEW.source_currency, ''))), '');
21199
+ BEGIN
21200
+ NEW.currency := v_unit_cur;
21201
+
21202
+ IF NEW.destination_location_id IS NOT NULL THEN
21203
+ SELECT l.unit_id INTO v_dst_unit FROM public.plg_inventory_stock_locations l WHERE l.id = NEW.destination_location_id;
21204
+ IF app.unit_currency(NEW.tenant_id, v_dst_unit) <> v_unit_cur THEN
21205
+ 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.',
21206
+ v_unit_cur, app.unit_currency(NEW.tenant_id, v_dst_unit)
21207
+ USING ERRCODE = '0A000';
21208
+ END IF;
21209
+ END IF;
21210
+
21211
+ IF v_src IS NULL OR v_src = v_unit_cur THEN
21212
+ NEW.source_currency := NULL; NEW.source_unit_cost := NULL; NEW.fx_rate := NULL;
21213
+ RETURN NEW;
21214
+ END IF;
21215
+
21216
+ NEW.source_currency := v_src;
21217
+ NEW.source_unit_cost := coalesce(NEW.source_unit_cost, NEW.unit_cost, 0);
21218
+ -- The tenant's rate table belongs to financial, which may not be installed.
21219
+ IF NEW.fx_rate IS NULL AND to_regprocedure('public.plg_financial_fx_rate(uuid,text,text,date)') IS NOT NULL THEN
21220
+ EXECUTE 'SELECT public.plg_financial_fx_rate($1, $2, $3, $4)'
21221
+ INTO NEW.fx_rate USING NEW.tenant_id, v_src, v_unit_cur, NEW.movement_date;
21222
+ END IF;
21223
+ IF NEW.fx_rate IS NULL OR NEW.fx_rate <= 0 THEN
21224
+ RAISE EXCEPTION 'Informe a taxa de c\xE2mbio de % para % desta entrada (ou cadastre a taxa do dia).', v_src, v_unit_cur
21225
+ USING ERRCODE = '22023';
21226
+ END IF;
21227
+ NEW.unit_cost := round(NEW.source_unit_cost * NEW.fx_rate, 4);
21228
+ NEW.total_cost := abs(NEW.quantity) * NEW.unit_cost;
21229
+ RETURN NEW;
21230
+ END $function$;
21231
+
21232
+ -- Named to fire after \`plg_inventory_stock_movements_before_insert\`, which
21233
+ -- stamps the unit and the cost this converts.
21234
+ DROP TRIGGER IF EXISTS plg_inventory_stock_movements_currency ON public.plg_inventory_stock_movements;
21235
+ CREATE TRIGGER plg_inventory_stock_movements_currency
21236
+ BEFORE INSERT ON public.plg_inventory_stock_movements
21237
+ FOR EACH ROW EXECUTE FUNCTION app.inventory_movements_currency();
21238
+
21239
+ CREATE OR REPLACE FUNCTION app.inventory_positions_currency()
21240
+ RETURNS trigger
21241
+ LANGUAGE plpgsql
21242
+ SECURITY DEFINER
21243
+ SET search_path TO ''
21244
+ AS $function$
21245
+ DECLARE v_unit uuid;
21246
+ BEGIN
21247
+ SELECT l.unit_id INTO v_unit FROM public.plg_inventory_stock_locations l WHERE l.id = NEW.stock_location_id;
21248
+ NEW.currency := app.unit_currency(NEW.tenant_id, coalesce(v_unit, NEW.unit_id));
21249
+ RETURN NEW;
21250
+ END $function$;
21251
+ DROP TRIGGER IF EXISTS plg_inventory_stock_positions_currency ON public.plg_inventory_stock_positions;
21252
+ CREATE TRIGGER plg_inventory_stock_positions_currency
21253
+ BEFORE INSERT ON public.plg_inventory_stock_positions
21254
+ FOR EACH ROW EXECUTE FUNCTION app.inventory_positions_currency();
21255
+
21256
+ -- The receipt passes the document's currency through. Patched in place: the
21257
+ -- live body differs from the file that created it.
21258
+ DO $patch$
21259
+ DECLARE
21260
+ v_fn regprocedure := 'public.inventory_receive(uuid, jsonb, jsonb, text)'::regprocedure;
21261
+ v_def text := pg_get_functiondef(v_fn);
21262
+ v_cols constant text := 'idempotency_key, line_no, operation_id, metadata)';
21263
+ v_vals constant text := 'v_key, v_no, v_op.op_id, v_ln.declared)';
21264
+ BEGIN
21265
+ IF position('source_currency' in v_def) > 0 THEN
21266
+ RETURN; -- already passes it (a replay, or a later rewrite)
21267
+ END IF;
21268
+ IF position(v_cols in v_def) = 0 OR position(v_vals in v_def) = 0 THEN
21269
+ RAISE EXCEPTION '026: inventory_receive no longer has the INSERT this extends; patch it by hand';
21270
+ END IF;
21271
+ v_def := overlay(v_def PLACING 'idempotency_key, line_no, operation_id, metadata, source_currency, fx_rate)'
21272
+ FROM position(v_cols in v_def) FOR length(v_cols));
21273
+ v_def := overlay(v_def PLACING
21274
+ 'v_key, v_no, v_op.op_id, v_ln.declared,' || E'\\n'
21275
+ || ' nullif(upper(btrim(coalesce(v_line->>''currency'', v_doc->>''currency'', ''''))), ''''),' || E'\\n'
21276
+ || ' coalesce(nullif(v_line->>''fx_rate'', '''')::numeric, nullif(v_doc->>''fx_rate'', '''')::numeric))'
21277
+ FROM position(v_vals in v_def) FOR length(v_vals));
21278
+ EXECUTE v_def;
21279
+ END $patch$;
21280
+ `;
21281
+ var MIGRATION_027_A_RECEIPT_NAMES_THE_LINE_IT_CAME_FROM = `-- ============================================================================
21282
+ -- 027 \u2014 a receipt names the line it came from
21283
+ --
21284
+ -- Stock received because of a bill had no tie to the bill line: the ledger
21285
+ -- kept \`source_item_type\` / \`source_item_id\` columns, but \`inventory_receive\`
21286
+ -- never filled them, so "did this line enter stock?" was answered by guessing
21287
+ -- from the invoice number. A line of the receipt can now say where it came
21288
+ -- from, and the receipt writes it down.
21289
+ --
21290
+ -- Patched in place, as 026 did: the live body differs from the files.
21291
+ -- ============================================================================
21292
+
21293
+ DO $patch$
21294
+ DECLARE
21295
+ v_fn regprocedure := 'public.inventory_receive(uuid, jsonb, jsonb, text)'::regprocedure;
21296
+ v_def text := pg_get_functiondef(v_fn);
21297
+ v_cols constant text := 'metadata, source_currency, fx_rate)';
21298
+ v_vals constant text := 'nullif(v_doc->>''fx_rate'', '''')::numeric))';
21299
+ BEGIN
21300
+ IF position('source_item_id' in v_def) > 0 THEN
21301
+ RETURN; -- already writes it (a replay, or a later rewrite)
21302
+ END IF;
21303
+ IF position(v_cols in v_def) = 0 OR position(v_vals in v_def) = 0 THEN
21304
+ RAISE EXCEPTION '027: inventory_receive no longer has the INSERT this extends; patch it by hand';
21305
+ END IF;
21306
+ v_def := overlay(v_def PLACING 'metadata, source_currency, fx_rate, source_item_type, source_item_id)'
21307
+ FROM position(v_cols in v_def) FOR length(v_cols));
21308
+ v_def := overlay(v_def PLACING
21309
+ 'nullif(v_doc->>''fx_rate'', '''')::numeric),' || E'\\n'
21310
+ || ' nullif(v_line->>''source_item_type'', ''''), nullif(v_line->>''source_item_id'', '''')::uuid)'
21311
+ FROM position(v_vals in v_def) FOR length(v_vals));
21312
+ EXECUTE v_def;
21313
+ END $patch$;
21314
+
21315
+ CREATE INDEX IF NOT EXISTS plg_inventory_stock_movements_source_item_idx
21316
+ ON public.plg_inventory_stock_movements (tenant_id, source_item_id) WHERE source_item_id IS NOT NULL;
21317
+ `;
21318
+ var MIGRATION_028_A_RECEIPT_KEEPS_THE_VARIANT = `-- ============================================================================
21319
+ -- 028 \u2014 a receipt keeps the variant
21320
+ --
21321
+ -- \`inventory_receive\` read \`variant_id\` off each line, checked it belonged to
21322
+ -- the product, and then did not write it: every receipt landed on the
21323
+ -- product's default variant. A shirt received as 10 M and 9 G was 19 of
21324
+ -- "Padr\xE3o". The line's variant now reaches the ledger row, which is what the
21325
+ -- per-variant balance is computed from.
21326
+ --
21327
+ -- Patched in place, as 026 and 027 did.
21328
+ --
21329
+ -- The column itself was never in this plugin's chain (022 says so): pools got
21330
+ -- it by hand, a database built from zero did not. It is declared here, in the
21331
+ -- shape the pools have. The value is read off the line's JSON, which exists in
21332
+ -- every version of the function; \`v_ln.variant_id\` only exists where the
21333
+ -- parser was widened by hand.
21334
+ -- ============================================================================
21335
+
21336
+ ALTER TABLE public.plg_inventory_stock_movements
21337
+ ADD COLUMN IF NOT EXISTS variant_id uuid REFERENCES public.product_variants(id) ON DELETE SET NULL;
21338
+ CREATE INDEX IF NOT EXISTS plg_inventory_stock_movements_variant_idx
21339
+ ON public.plg_inventory_stock_movements (tenant_id, variant_id) WHERE variant_id IS NOT NULL;
21340
+
21341
+ DO $patch$
21342
+ DECLARE
21343
+ v_fn regprocedure := 'public.inventory_receive(uuid, jsonb, jsonb, text)'::regprocedure;
21344
+ v_def text := pg_get_functiondef(v_fn);
21345
+ v_cols constant text := 'source_item_type, source_item_id)';
21346
+ v_vals constant text := 'nullif(v_line->>''source_item_id'', '''')::uuid)';
21347
+ BEGIN
21348
+ IF position('source_item_type, source_item_id, variant_id)' in v_def) > 0 THEN
21349
+ RETURN; -- already writes it (a replay, or a later rewrite)
21350
+ END IF;
21351
+ IF position(v_cols in v_def) = 0 OR position(v_vals in v_def) = 0 THEN
21352
+ RAISE EXCEPTION '028: inventory_receive no longer has the INSERT this extends; patch it by hand';
21353
+ END IF;
21354
+ v_def := overlay(v_def PLACING 'source_item_type, source_item_id, variant_id)'
21355
+ FROM position(v_cols in v_def) FOR length(v_cols));
21356
+ v_def := overlay(v_def PLACING 'nullif(v_line->>''source_item_id'', '''')::uuid, nullif(v_line->>''variant_id'', '''')::uuid)'
21357
+ FROM position(v_vals in v_def) FOR length(v_vals));
21358
+ EXECUTE v_def;
21359
+ END $patch$;
21360
+ `;
21361
+ var MIGRATION_029_A_REVERSAL_UNDOES_THE_SAME_VARIANT = `-- ============================================================================
21362
+ -- 029 \u2014 a reversal undoes the same variant
21363
+ --
21364
+ -- 028 made a receipt keep the variant it was for. \`inventory_reverse\` copies
21365
+ -- the row it undoes and left the variant out, so undoing "6 of M" tried to
21366
+ -- take 6 from the product's default variant \u2014 which has none, and the
21367
+ -- reversal was refused ("would take variant \u2026 below zero"). A receipt by size
21368
+ -- could be made and never corrected.
21369
+ --
21370
+ -- The reversal now carries the variant of the row it reverses.
21371
+ -- Patched in place, like 026\u2013028.
21372
+ -- ============================================================================
21373
+
21374
+ DO $patch$
21375
+ DECLARE
21376
+ v_fn regprocedure := 'public.inventory_reverse(text, text)'::regprocedure;
21377
+ v_def text := pg_get_functiondef(v_fn);
21378
+ v_new text;
21379
+ -- The function has two INSERTs (one per branch); both copy the row they undo.
21380
+ v_cols constant text := 'reverses_movement_id, idempotency_key, line_no, operation_id, metadata)';
21381
+ v_vals1 constant text := 'jsonb_build_object(''reversed_movement_id'', r.id))';
21382
+ v_vals2 constant text := '''reversed_kind'', r.kind))';
21383
+ BEGIN
21384
+ IF position('r.variant_id' in v_def) > 0 THEN
21385
+ RETURN; -- already carries it (a replay, or a later rewrite)
21386
+ END IF;
21387
+ v_new := replace(v_def, v_cols, 'reverses_movement_id, idempotency_key, line_no, operation_id, metadata, variant_id)');
21388
+ v_new := replace(v_new, v_vals1, 'jsonb_build_object(''reversed_movement_id'', r.id), r.variant_id)');
21389
+ v_new := replace(v_new, v_vals2, '''reversed_kind'', r.kind), r.variant_id)');
21390
+ -- Every INSERT that gained the column must have gained the value.
21391
+ IF (length(v_new) - length(replace(v_new, 'metadata, variant_id)', ''))) / length('metadata, variant_id)')
21392
+ <> (length(v_new) - length(replace(v_new, ', r.variant_id)', ''))) / length(', r.variant_id)')
21393
+ OR position('metadata, variant_id)' in v_new) = 0 THEN
21394
+ RAISE EXCEPTION '029: inventory_reverse no longer has the INSERTs this extends; patch it by hand';
21395
+ END IF;
21396
+ EXECUTE v_new;
21397
+ END $patch$;
21398
+ `;
20877
21399
  var MIGRATIONS = [
20878
21400
  { id: "000_baseline", sql: MIGRATION_000_BASELINE },
20879
21401
  { id: "001_the_usage_that_left_stock_can_be_audited", sql: MIGRATION_001_THE_USAGE_THAT_LEFT_STOCK_CAN_BE_AUDITED },
@@ -20900,7 +21422,11 @@ var MIGRATIONS = [
20900
21422
  { id: "022_receber_estoque_volta_a_funcionar", sql: MIGRATION_022_RECEBER_ESTOQUE_VOLTA_A_FUNCIONAR },
20901
21423
  { id: "023_a_receita_se_escreve_com_permissao", sql: MIGRATION_023_A_RECEITA_SE_ESCREVE_COM_PERMISSAO },
20902
21424
  { id: "024_a_categoria_do_produto_e_do_catalogo", sql: MIGRATION_024_A_CATEGORIA_DO_PRODUTO_E_DO_CATALOGO },
20903
- { id: "025_a_ficha_tecnica_por_estacao", sql: MIGRATION_025_A_FICHA_TECNICA_POR_ESTACAO }
21425
+ { id: "025_a_ficha_tecnica_por_estacao", sql: MIGRATION_025_A_FICHA_TECNICA_POR_ESTACAO },
21426
+ { id: "026_stock_cost_knows_its_currency", sql: MIGRATION_026_STOCK_COST_KNOWS_ITS_CURRENCY },
21427
+ { id: "027_a_receipt_names_the_line_it_came_from", sql: MIGRATION_027_A_RECEIPT_NAMES_THE_LINE_IT_CAME_FROM },
21428
+ { id: "028_a_receipt_keeps_the_variant", sql: MIGRATION_028_A_RECEIPT_KEEPS_THE_VARIANT },
21429
+ { id: "029_a_reversal_undoes_the_same_variant", sql: MIGRATION_029_A_REVERSAL_UNDOES_THE_SAME_VARIANT }
20904
21430
  ];
20905
21431
 
20906
21432
  // src/index.ts
@@ -20940,6 +21466,7 @@ function resolveConfig(options) {
20940
21466
  locations: options?.locations ?? [],
20941
21467
  moduleNav: options?.moduleNav,
20942
21468
  extraViews: options?.extraViews ?? [],
21469
+ productDetailTabs: options?.productDetailTabs ?? [],
20943
21470
  documentModal: options?.documentModal
20944
21471
  };
20945
21472
  }
@@ -20955,7 +21482,7 @@ function createInventoryPlugin(options) {
20955
21482
  const provider = options?.dataProvider ?? createInventoryDataProvider();
20956
21483
  const store = createInventoryStore(provider);
20957
21484
  const dashboardWidgets = createInventoryDashboardWidgets({ config, provider, store });
20958
- const PageComponent = () => React4.createElement(InventoryPage, { config, provider, store, registries: inventoryRegistries });
21485
+ const PageComponent = () => React14.createElement(InventoryPage, { config, provider, store, registries: inventoryRegistries });
20959
21486
  return {
20960
21487
  id: "inventory",
20961
21488
  // Como esta conta refaz o módulo com as opções DELA. Ver PluginRebuild:
@@ -21124,9 +21651,9 @@ function createInventoryPlugin(options) {
21124
21651
  // Categories, Units and Suppliers all existed in code and appeared
21125
21652
  // nowhere. financial and crm already render through it.
21126
21653
  component: (() => {
21127
- const Tab = () => React4.createElement(PluginSettingsPanel, {
21654
+ const Tab = () => React14.createElement(PluginSettingsPanel, {
21128
21655
  title: config.labels.pageTitle,
21129
- generalSettings: React4.createElement(InventoryGeneralSettings, { config }),
21656
+ generalSettings: React14.createElement(InventoryGeneralSettings, { config }),
21130
21657
  registries: inventoryRegistries,
21131
21658
  hostPluginId: "inventory",
21132
21659
  routeBase: "/settings/inventory"