@fayz-ai/plugin-inventory 0.12.1 → 0.12.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/data/types.d.ts +9 -1
- package/dist/data/types.d.ts.map +1 -1
- package/dist/index.js +638 -78
- package/dist/index.js.map +1 -1
- package/dist/lib/idempotency.d.ts +2 -0
- package/dist/lib/idempotency.d.ts.map +1 -0
- package/dist/lib/setup-guides.d.ts.map +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/store.d.ts +6 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/types.d.ts +29 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/views/InventoryPage.d.ts.map +1 -1
- package/dist/views/MovementHistoryView.d.ts.map +1 -1
- package/dist/views/ProductStockTab.d.ts.map +1 -1
- package/dist/views/StockCountSessionView.d.ts.map +1 -1
- package/dist/views/StockMovementView.d.ts.map +1 -1
- package/dist/views/dashboardWidgets.d.ts.map +1 -1
- package/dist/views/productEntity.d.ts.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import React3, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
|
2
|
-
import { toast, defineKpiWidget, defineCustomWidget, PageTransition, ModulePage, Button, SubpageHeader, SearchSelect, cn, DatePicker, ListView, ConfirmDialog, Badge, DraftCommitBar, Switch, useSaveBar, DataTable, DashboardCanvas, Modal, ModalContent, ModalHeader, ModalTitle, ModalDescription, ModalBody, ModalFooter, StockEntryForm, KpiCard, Card, CardHeader, CardTitle, CardContent } from '@fayz-ai/ui';
|
|
2
|
+
import { toast, defineKpiWidget, defineCustomWidget, PageTransition, ModulePage, Button, SubpageHeader, SearchSelect, cn, DatePicker, ListView, ConfirmDialog, Badge, DraftCommitBar, Switch, useSaveBar, DataTable, DashboardCanvas, Modal, ModalContent, ModalHeader, ModalTitle, ModalDescription, ModalBody, ModalFooter, StockEntryForm, KpiCard, Card, CardHeader, CardTitle, CardContent, Skeleton } from '@fayz-ai/ui';
|
|
3
3
|
import { createPluginContext, PluginSettingsPanel, dedup, useModuleNavigation, createViewRouter, ModuleActionBar, useTenantPluginSettings, SettingsGroup, ToggleRow, usePermissionOptional, CrudListView, TeachMeLink, useLimitGuard, invalidateRelationOptions, CrudFormSkeleton, CrudFormPage, CrudDetailPage, formatCurrency, useAgentSurface, askAI, PermissionGate, QuickActionsButton, invalidateLimit } from '@fayz-ai/admin';
|
|
4
|
-
import { createSafeDataProvider, registerTranslations, countByTenant, useTranslation, getActiveUnitId, errorMessage, useDataChanged, getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core';
|
|
4
|
+
import { createSafeDataProvider, registerTranslations, countByTenant, useTranslation, getActiveUnitId, createPendingCommand, errorMessage, useDataChanged, getSupabaseClientOptional, getActiveTenantId } 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, ClipboardCheck, AlertTriangle, Plus, BookOpen, Layers, Clock, Coins, Building2, Undo2 } from 'lucide-react';
|
|
6
|
+
import { Trash2, ArrowRightLeft, RefreshCw, ArrowUpRight, ArrowDownRight, Package, Ruler, MapPin, Loader2, Check, ChevronRight, CircleSlash, CircleCheck, ClipboardCheck, AlertTriangle, Plus, BookOpen, Layers, Clock, Coins, Building2, Undo2, PackageX } from 'lucide-react';
|
|
7
7
|
import { createStore as createStore$1 } from 'zustand/vanilla';
|
|
8
8
|
|
|
9
9
|
// src/index.ts
|
|
@@ -70,6 +70,17 @@ function normalizeMovementType(raw) {
|
|
|
70
70
|
if (MOVEMENT_TYPE_VALUES.includes(v)) return v;
|
|
71
71
|
return LEDGER_KIND_TO_TYPE[v] ?? LEGACY_PT_MOVEMENT_TYPE[v];
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
// src/lib/idempotency.ts
|
|
75
|
+
function createIdempotencyKey(scope) {
|
|
76
|
+
let suffix;
|
|
77
|
+
try {
|
|
78
|
+
suffix = typeof globalThis.crypto?.randomUUID === "function" ? globalThis.crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
79
|
+
} catch {
|
|
80
|
+
suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
81
|
+
}
|
|
82
|
+
return `${scope}:${suffix}`;
|
|
83
|
+
}
|
|
73
84
|
function TableSkeleton() {
|
|
74
85
|
return /* @__PURE__ */ jsx("div", { className: "rounded-card border p-4 space-y-2", children: [1, 2, 3].map((i) => /* @__PURE__ */ jsx("div", { className: "h-3.5 rounded bg-muted/30 animate-pulse" }, i)) });
|
|
75
86
|
}
|
|
@@ -87,6 +98,8 @@ function ProductStockTab({ item, onChanged }) {
|
|
|
87
98
|
const [undoing, setUndoing] = useState(false);
|
|
88
99
|
const [entryVerb, setEntryVerb] = useState(null);
|
|
89
100
|
const [saving, setSaving] = useState(false);
|
|
101
|
+
const command = useRef(createPendingCommand(() => createIdempotencyKey("inventory:product-movement"), (input) => input.productId));
|
|
102
|
+
const [pending, setPending] = useState(false);
|
|
90
103
|
const load = useCallback(async () => {
|
|
91
104
|
setLoading(true);
|
|
92
105
|
try {
|
|
@@ -104,25 +117,32 @@ function ProductStockTab({ item, onChanged }) {
|
|
|
104
117
|
void load();
|
|
105
118
|
}, [load]);
|
|
106
119
|
const handleEntry = useCallback(async (entry) => {
|
|
120
|
+
if (command.current.running) return;
|
|
107
121
|
setSaving(true);
|
|
122
|
+
setPending(true);
|
|
108
123
|
try {
|
|
109
|
-
await
|
|
124
|
+
await command.current.run({
|
|
110
125
|
productId: item.id,
|
|
111
126
|
movementType: entry.verb,
|
|
112
127
|
quantity: entry.quantity,
|
|
113
128
|
...entry.reasonCode ? { reason: entry.reasonCode } : {},
|
|
114
129
|
...entry.note ? { notes: entry.note } : {},
|
|
115
130
|
...entry.occurredOn ? { movementDate: entry.occurredOn } : {}
|
|
116
|
-
});
|
|
131
|
+
}, (input) => provider.createMovement(input));
|
|
117
132
|
setEntryVerb(null);
|
|
118
|
-
|
|
119
|
-
|
|
133
|
+
try {
|
|
134
|
+
await load();
|
|
135
|
+
onChanged?.();
|
|
136
|
+
} catch {
|
|
137
|
+
toast.warning(t2("inventory.stock.refreshAfterCommit"));
|
|
138
|
+
}
|
|
120
139
|
} catch (error) {
|
|
121
140
|
toast.error(t2("inventory.productDetail.movementFailed"), {
|
|
122
141
|
description: error.message
|
|
123
142
|
});
|
|
124
143
|
} finally {
|
|
125
144
|
setSaving(false);
|
|
145
|
+
setPending(command.current.pending);
|
|
126
146
|
}
|
|
127
147
|
}, [item.id, provider, load, onChanged, t2]);
|
|
128
148
|
const ordered = useMemo(
|
|
@@ -260,8 +280,12 @@ function ProductStockTab({ item, onChanged }) {
|
|
|
260
280
|
StockEntryForm,
|
|
261
281
|
{
|
|
262
282
|
verb: entryVerb,
|
|
263
|
-
onVerbChange:
|
|
264
|
-
|
|
283
|
+
onVerbChange: (verb) => {
|
|
284
|
+
if (!command.current.pending) setEntryVerb(verb);
|
|
285
|
+
},
|
|
286
|
+
onClose: () => {
|
|
287
|
+
if (!command.current.pending) setEntryVerb(null);
|
|
288
|
+
},
|
|
265
289
|
verbs: MOVEMENT_TYPES.map((m) => ({
|
|
266
290
|
value: m.value,
|
|
267
291
|
label: t2(m.labelKey),
|
|
@@ -269,6 +293,8 @@ function ProductStockTab({ item, onChanged }) {
|
|
|
269
293
|
})),
|
|
270
294
|
available: onHand,
|
|
271
295
|
saving,
|
|
296
|
+
readOnly: pending,
|
|
297
|
+
notice: pending ? t2("inventory.stock.pendingRetry") : void 0,
|
|
272
298
|
onSubmit: (entry) => void handleEntry(entry),
|
|
273
299
|
labels: {
|
|
274
300
|
title: t2("inventory.productDetail.newMovement"),
|
|
@@ -360,6 +386,16 @@ function buildProductEntity(t2, productTypes, currency, onStockChanged, provider
|
|
|
360
386
|
renderCell: (v) => /* @__PURE__ */ jsx("span", { className: "inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium bg-muted text-muted-foreground", children: typeLabel(v) })
|
|
361
387
|
},
|
|
362
388
|
// — Stock column (list only) — placed before Cost for the desired column order
|
|
389
|
+
//
|
|
390
|
+
// A célula carrega o saldo E o mínimo, porque a pergunta do balcão é a
|
|
391
|
+
// COMPARAÇÃO entre os dois — no V1 são duas colunas e o olho faz a conta.
|
|
392
|
+
// Uma coluna a mais custaria a sétima numa tabela que já é larga.
|
|
393
|
+
//
|
|
394
|
+
// E o alarme obedece à mesma regra do resumo (`stock > 0 && stock <= min`).
|
|
395
|
+
// Antes era `currentQuantity <= minQuantity`, que com mínimo 0 — o caso de
|
|
396
|
+
// 169 dos 169 produtos deste tenant — pinta o catálogo INTEIRO de vermelho.
|
|
397
|
+
// Alarme que toca sempre é alarme que ninguém ouve: é literalmente o defeito
|
|
398
|
+
// do V1 que a Visão Geral documenta (203 de 218 "em reposição urgente").
|
|
363
399
|
{
|
|
364
400
|
key: "currentQuantity",
|
|
365
401
|
label: t2("inventory.productList.stock"),
|
|
@@ -367,7 +403,23 @@ function buildProductEntity(t2, productTypes, currency, onStockChanged, provider
|
|
|
367
403
|
showInForm: false,
|
|
368
404
|
showInTable: true,
|
|
369
405
|
sortable: true,
|
|
370
|
-
renderCell: (_v, row) =>
|
|
406
|
+
renderCell: (_v, row) => {
|
|
407
|
+
const qty2 = Number(row.currentQuantity) || 0;
|
|
408
|
+
const min = Number(row.minQuantity) || 0;
|
|
409
|
+
const unit = row.measurementUnitName;
|
|
410
|
+
const below = min > 0 && qty2 > 0 && qty2 <= min;
|
|
411
|
+
const empty = qty2 <= 0;
|
|
412
|
+
return /* @__PURE__ */ jsxs("span", { className: "block text-right", children: [
|
|
413
|
+
/* @__PURE__ */ jsxs("span", { className: below ? "font-medium text-destructive" : empty ? "text-muted-foreground" : "", children: [
|
|
414
|
+
qty2.toLocaleString("pt-BR", { maximumFractionDigits: 3 }),
|
|
415
|
+
unit ? /* @__PURE__ */ jsx("span", { className: "ml-1 text-xs text-muted-foreground", children: unit }) : null
|
|
416
|
+
] }),
|
|
417
|
+
min > 0 && /* @__PURE__ */ jsxs("span", { className: "block text-xs text-muted-foreground", children: [
|
|
418
|
+
"m\xEDn. ",
|
|
419
|
+
min.toLocaleString("pt-BR", { maximumFractionDigits: 3 })
|
|
420
|
+
] })
|
|
421
|
+
] });
|
|
422
|
+
}
|
|
371
423
|
},
|
|
372
424
|
// — Pricing (form) + Cost column (table) —
|
|
373
425
|
{
|
|
@@ -380,7 +432,12 @@ function buildProductEntity(t2, productTypes, currency, onStockChanged, provider
|
|
|
380
432
|
currencyLocale: currency.locale,
|
|
381
433
|
showInTable: true,
|
|
382
434
|
sortable: false,
|
|
383
|
-
|
|
435
|
+
// Mesma regra do preço de venda: custo zero é "não cadastrado", não
|
|
436
|
+
// "custa nada". E é ele que faz o Valor abaixo mentir.
|
|
437
|
+
renderCell: (v) => {
|
|
438
|
+
const n = Number(v);
|
|
439
|
+
return /* @__PURE__ */ jsx("span", { className: "block text-right text-muted-foreground", children: v == null || v === "" || !Number.isFinite(n) || n === 0 ? "\u2014" : formatCurrency(n, currency) });
|
|
440
|
+
}
|
|
384
441
|
},
|
|
385
442
|
// — Value column (list only): stock × cost —
|
|
386
443
|
{
|
|
@@ -390,7 +447,19 @@ function buildProductEntity(t2, productTypes, currency, onStockChanged, provider
|
|
|
390
447
|
showInForm: false,
|
|
391
448
|
showInTable: true,
|
|
392
449
|
sortable: false,
|
|
393
|
-
|
|
450
|
+
// Saldo em mãos SEM custo cadastrado não vale R$ 0,00 — vale um número
|
|
451
|
+
// que esta casa não sabe. É o beco que o V1 documenta nas movimentações:
|
|
452
|
+
// "o Valor Total se descola da realidade sem ninguém errar nada". Zero
|
|
453
|
+
// com saldo zero é verdade; zero com 84 unidades na prateleira é uma
|
|
454
|
+
// afirmação que o dado não sustenta.
|
|
455
|
+
renderCell: (_v, row) => {
|
|
456
|
+
const qty2 = Number(row.currentQuantity) || 0;
|
|
457
|
+
const cost = Number(row.costPrice) || 0;
|
|
458
|
+
if (qty2 > 0 && cost === 0) {
|
|
459
|
+
return /* @__PURE__ */ jsx("span", { className: "block text-right text-muted-foreground", title: "Sem custo cadastrado, o valor deste saldo n\xE3o pode ser calculado", children: "\u2014" });
|
|
460
|
+
}
|
|
461
|
+
return /* @__PURE__ */ jsx("span", { className: "block text-right font-medium", children: formatCurrency(qty2 * cost, currency) });
|
|
462
|
+
}
|
|
394
463
|
},
|
|
395
464
|
{
|
|
396
465
|
key: "salePrice",
|
|
@@ -400,7 +469,16 @@ function buildProductEntity(t2, productTypes, currency, onStockChanged, provider
|
|
|
400
469
|
currency: currency.code,
|
|
401
470
|
currencySymbol: currency.symbol,
|
|
402
471
|
currencyLocale: currency.locale,
|
|
403
|
-
|
|
472
|
+
// O V1 mostra Custo E Venda lado a lado. Custo sozinho não responde
|
|
473
|
+
// "quanto isto rende", e o preço estava só dentro da ficha.
|
|
474
|
+
showInTable: true,
|
|
475
|
+
// Zero aqui é "não informado", não "de graça" — 100 dos 169 produtos deste
|
|
476
|
+
// tenant estão assim. "R$ 0,00" afirmaria um preço; o travessão diz que
|
|
477
|
+
// falta cadastrar, que é a verdade e é o que se pode agir sobre.
|
|
478
|
+
renderCell: (v) => {
|
|
479
|
+
const n = Number(v);
|
|
480
|
+
return /* @__PURE__ */ jsx("span", { className: "block text-right text-muted-foreground", children: v == null || v === "" || !Number.isFinite(n) || n === 0 ? "\u2014" : formatCurrency(n, currency) });
|
|
481
|
+
},
|
|
404
482
|
// Ingredients and intermediate preparations are inputs, not things sold
|
|
405
483
|
// directly to the customer. If it is sold, it belongs to the menu-item
|
|
406
484
|
// classification and gets the commercial fields there.
|
|
@@ -933,6 +1011,8 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
933
1011
|
const [supplierLabel, setSupplierLabel] = useState("");
|
|
934
1012
|
const [saving, setSaving] = useState(false);
|
|
935
1013
|
const [savedMovement, setSavedMovement] = useState(null);
|
|
1014
|
+
const command = useRef(createPendingCommand(() => createIdempotencyKey("inventory:manual-movement"), (input) => input.productId));
|
|
1015
|
+
const [pending, setPending] = useState(false);
|
|
936
1016
|
useEffect(() => {
|
|
937
1017
|
fetchProducts({});
|
|
938
1018
|
fetchLocations();
|
|
@@ -1000,10 +1080,11 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1000
1080
|
const canProceedStep2 = quantity > 0 && (!needsReason || reason.trim()) && (!needsDest || destLocationId);
|
|
1001
1081
|
const title = defaultType === "entry" ? t2("inventory.stock.entry") : defaultType === "exit" ? t2("inventory.stock.exit") : t2("inventory.stock.movement");
|
|
1002
1082
|
async function handleSave() {
|
|
1003
|
-
if (!productId || quantity <= 0) return;
|
|
1083
|
+
if (command.current.running || !command.current.pending && (!productId || quantity <= 0)) return;
|
|
1004
1084
|
setSaving(true);
|
|
1085
|
+
setPending(true);
|
|
1005
1086
|
try {
|
|
1006
|
-
const movement = await
|
|
1087
|
+
const movement = await command.current.run({
|
|
1007
1088
|
productId,
|
|
1008
1089
|
quantity,
|
|
1009
1090
|
movementType,
|
|
@@ -1016,10 +1097,12 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1016
1097
|
batchNumber: batchNumber || void 0,
|
|
1017
1098
|
expirationDate: expirationDate || void 0,
|
|
1018
1099
|
supplierId: movementType === "entry" ? supplierId || void 0 : void 0
|
|
1019
|
-
});
|
|
1100
|
+
}, createMovement);
|
|
1020
1101
|
setSavedMovement(movement);
|
|
1102
|
+
} catch {
|
|
1021
1103
|
} finally {
|
|
1022
1104
|
setSaving(false);
|
|
1105
|
+
setPending(command.current.pending);
|
|
1023
1106
|
}
|
|
1024
1107
|
}
|
|
1025
1108
|
return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
|
|
@@ -1028,7 +1111,12 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1028
1111
|
{
|
|
1029
1112
|
title,
|
|
1030
1113
|
subtitle: t2("inventory.stock.stepOf", { step: String(step) }),
|
|
1031
|
-
onBack:
|
|
1114
|
+
onBack: () => {
|
|
1115
|
+
if (!command.current.pending && !command.current.running) {
|
|
1116
|
+
if (step > 1) setStep(step - 1);
|
|
1117
|
+
else onSaved?.();
|
|
1118
|
+
}
|
|
1119
|
+
},
|
|
1032
1120
|
parentLabel: t2("inventory.nav.stock")
|
|
1033
1121
|
}
|
|
1034
1122
|
),
|
|
@@ -1057,6 +1145,7 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1057
1145
|
/* @__PURE__ */ jsx(
|
|
1058
1146
|
SearchSelect,
|
|
1059
1147
|
{
|
|
1148
|
+
"data-tour": "inventory.entry-product",
|
|
1060
1149
|
value: productId,
|
|
1061
1150
|
displayValue: productLabel,
|
|
1062
1151
|
onChange: handleProductSelect,
|
|
@@ -1138,7 +1227,7 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1138
1227
|
/* @__PURE__ */ jsxs("div", { children: [
|
|
1139
1228
|
/* @__PURE__ */ jsx("label", { className: "text-xs font-medium text-muted-foreground", children: needsDest ? t2("inventory.stock.fromLocation") : t2("inventory.stock.locationLabel") }),
|
|
1140
1229
|
/* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2 mt-1.5", children: sortedLocations.map((l) => {
|
|
1141
|
-
const
|
|
1230
|
+
const qty2 = positionByLocation[l.id] ?? 0;
|
|
1142
1231
|
return /* @__PURE__ */ jsxs(
|
|
1143
1232
|
"button",
|
|
1144
1233
|
{
|
|
@@ -1152,8 +1241,8 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1152
1241
|
/* @__PURE__ */ jsx("span", { children: l.name }),
|
|
1153
1242
|
productId && /* @__PURE__ */ jsx("span", { className: cn(
|
|
1154
1243
|
"inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold tabular-nums",
|
|
1155
|
-
|
|
1156
|
-
), children:
|
|
1244
|
+
qty2 > 0 ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
|
1245
|
+
), children: qty2 })
|
|
1157
1246
|
]
|
|
1158
1247
|
},
|
|
1159
1248
|
l.id
|
|
@@ -1163,7 +1252,7 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1163
1252
|
needsDest && /* @__PURE__ */ jsxs("div", { children: [
|
|
1164
1253
|
/* @__PURE__ */ jsx("label", { className: "text-xs font-medium text-muted-foreground", children: t2("inventory.stock.toLocation") }),
|
|
1165
1254
|
/* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2 mt-1.5", children: sortedLocations.filter((l) => l.id !== locationId).map((l) => {
|
|
1166
|
-
const
|
|
1255
|
+
const qty2 = positionByLocation[l.id] ?? 0;
|
|
1167
1256
|
return /* @__PURE__ */ jsxs(
|
|
1168
1257
|
"button",
|
|
1169
1258
|
{
|
|
@@ -1177,8 +1266,8 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1177
1266
|
/* @__PURE__ */ jsx("span", { children: l.name }),
|
|
1178
1267
|
productId && /* @__PURE__ */ jsx("span", { className: cn(
|
|
1179
1268
|
"inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold tabular-nums",
|
|
1180
|
-
|
|
1181
|
-
), children:
|
|
1269
|
+
qty2 > 0 ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
|
1270
|
+
), children: qty2 })
|
|
1182
1271
|
]
|
|
1183
1272
|
},
|
|
1184
1273
|
l.id
|
|
@@ -1238,7 +1327,8 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1238
1327
|
] })
|
|
1239
1328
|
] })
|
|
1240
1329
|
] }),
|
|
1241
|
-
/* @__PURE__ */
|
|
1330
|
+
pending && /* @__PURE__ */ jsx("p", { role: "status", className: "text-sm text-warning", children: t2("inventory.stock.pendingRetry") }),
|
|
1331
|
+
/* @__PURE__ */ jsxs("fieldset", { disabled: pending, className: "rounded-card border bg-card shadow-sm p-5 space-y-4", children: [
|
|
1242
1332
|
/* @__PURE__ */ jsx("p", { className: "text-xs font-medium text-muted-foreground", children: t2("inventory.stock.additionalDetails") }),
|
|
1243
1333
|
/* @__PURE__ */ jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [
|
|
1244
1334
|
movementType === "entry" && /* @__PURE__ */ jsx(
|
|
@@ -1279,10 +1369,13 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1279
1369
|
] })
|
|
1280
1370
|
] }),
|
|
1281
1371
|
/* @__PURE__ */ jsxs("div", { className: "flex justify-between", children: [
|
|
1282
|
-
/* @__PURE__ */ jsx("button", {
|
|
1372
|
+
/* @__PURE__ */ jsx("button", { disabled: pending, onClick: () => {
|
|
1373
|
+
if (!command.current.pending) setStep(2);
|
|
1374
|
+
}, className: "rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted bg-card shadow-button active:shadow-button-inset transition-colors", children: t2("inventory.stock.back") }),
|
|
1283
1375
|
/* @__PURE__ */ jsxs(
|
|
1284
1376
|
"button",
|
|
1285
1377
|
{
|
|
1378
|
+
"data-tour": "inventory.entry-save",
|
|
1286
1379
|
onClick: handleSave,
|
|
1287
1380
|
disabled: saving,
|
|
1288
1381
|
className: "inline-flex items-center gap-1.5 rounded-button bg-primary border border-primary px-5 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 shadow-button-primary active:shadow-button-inset transition-colors disabled:opacity-50",
|
|
@@ -1336,9 +1429,30 @@ function MovementHistoryView({ onViewDetail, onNew, onNewExit } = {}) {
|
|
|
1336
1429
|
cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: getValue() })
|
|
1337
1430
|
},
|
|
1338
1431
|
{
|
|
1432
|
+
// O MOTIVO entra debaixo do produto, não como sétima coluna.
|
|
1433
|
+
//
|
|
1434
|
+
// É o campo central do razão do V1 — "nenhum movimento existe sem motivo"
|
|
1435
|
+
// — e estava gravado e invisível: as entradas deste tenant carregam
|
|
1436
|
+
// "Entrada via DANFE NF-e 10467692" desde a migração, que é a resposta
|
|
1437
|
+
// exata para a pergunta nº 1 do usuário no V1 ("de onde vieram essas
|
|
1438
|
+
// entradas que eu não lancei?"). Como legenda ele fica onde o olho já
|
|
1439
|
+
// está, em vez de numa coluna a mais numa tabela que já tem seis.
|
|
1339
1440
|
accessorKey: "productName",
|
|
1340
1441
|
header: t2("inventory.history.columnProduct"),
|
|
1341
|
-
cell: ({
|
|
1442
|
+
cell: ({ row }) => {
|
|
1443
|
+
const reason = row.original.reason || void 0;
|
|
1444
|
+
const doc = row.original.documentNumber || void 0;
|
|
1445
|
+
const printableDoc = doc && !/^[0-9a-f-]{32,36}$/i.test(doc) ? doc : void 0;
|
|
1446
|
+
const caption = reason ?? (printableDoc ? `Documento ${printableDoc}` : void 0);
|
|
1447
|
+
return /* @__PURE__ */ jsxs("span", { className: "block", children: [
|
|
1448
|
+
/* @__PURE__ */ jsx("span", { className: "block font-medium", children: row.original.productName ?? "\u2014" }),
|
|
1449
|
+
caption ? /* @__PURE__ */ jsx("span", { className: "block truncate text-xs text-muted-foreground", title: caption, children: caption }) : (
|
|
1450
|
+
// Movimento sem motivo é um buraco no razão, e o V1 não deixa
|
|
1451
|
+
// criar um pela tela. Dizê-lo é o que faz alguém parar de criar.
|
|
1452
|
+
/* @__PURE__ */ jsx("span", { className: "block text-xs italic text-muted-foreground", children: "Sem motivo registrado" })
|
|
1453
|
+
)
|
|
1454
|
+
] });
|
|
1455
|
+
}
|
|
1342
1456
|
},
|
|
1343
1457
|
{
|
|
1344
1458
|
accessorKey: "movementType",
|
|
@@ -1372,7 +1486,25 @@ function MovementHistoryView({ onViewDetail, onNew, onNewExit } = {}) {
|
|
|
1372
1486
|
{
|
|
1373
1487
|
accessorKey: "totalCost",
|
|
1374
1488
|
header: t2("inventory.history.columnTotal"),
|
|
1375
|
-
|
|
1489
|
+
// Custo é OPCIONAL na entrada, e o V1 documenta o beco que isso abre: o
|
|
1490
|
+
// saldo de quantidade sobe, o valor do estoque não, e o "Valor Total" da
|
|
1491
|
+
// visão geral se descola da realidade sem ninguém errar nada. "R$ 0,00"
|
|
1492
|
+
// esconde isso atrás de um número; o travessão com a explicação no hover
|
|
1493
|
+
// diz que o movimento entrou sem valor — que é o fato.
|
|
1494
|
+
cell: ({ getValue }) => {
|
|
1495
|
+
const n = Number(getValue());
|
|
1496
|
+
if (!Number.isFinite(n) || n === 0) {
|
|
1497
|
+
return /* @__PURE__ */ jsx(
|
|
1498
|
+
"span",
|
|
1499
|
+
{
|
|
1500
|
+
className: "block text-right text-muted-foreground",
|
|
1501
|
+
title: "Movimento registrado sem custo: o saldo mudou, o valor do estoque n\xE3o",
|
|
1502
|
+
children: "\u2014"
|
|
1503
|
+
}
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1506
|
+
return /* @__PURE__ */ jsx("span", { className: "block text-right text-muted-foreground", children: formatCurrency(n, currency) });
|
|
1507
|
+
}
|
|
1376
1508
|
}
|
|
1377
1509
|
], [currency, t2]);
|
|
1378
1510
|
return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
|
|
@@ -1705,6 +1837,15 @@ function StockCountSessionView({ sessionId, onBack }) {
|
|
|
1705
1837
|
setConfirmingClose(false);
|
|
1706
1838
|
}
|
|
1707
1839
|
}
|
|
1840
|
+
function requestClose() {
|
|
1841
|
+
if (summary.uncounted > 0) {
|
|
1842
|
+
toast.error(t2("inventory.counts.incomplete"), {
|
|
1843
|
+
description: t2("inventory.counts.incompleteDescription", { count: String(summary.uncounted) })
|
|
1844
|
+
});
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
setConfirmingClose(true);
|
|
1848
|
+
}
|
|
1708
1849
|
async function handleCancel() {
|
|
1709
1850
|
try {
|
|
1710
1851
|
await provider.cancelCountSession(sessionId);
|
|
@@ -1770,7 +1911,7 @@ function StockCountSessionView({ sessionId, onBack }) {
|
|
|
1770
1911
|
] }),
|
|
1771
1912
|
!closed && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
1772
1913
|
/* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: () => setConfirmingCancel(true), children: t2("inventory.counts.cancelCount") }),
|
|
1773
|
-
/* @__PURE__ */ jsx(Button, { size: "sm", onClick:
|
|
1914
|
+
/* @__PURE__ */ jsx(Button, { size: "sm", onClick: requestClose, children: t2("inventory.counts.close") })
|
|
1774
1915
|
] })
|
|
1775
1916
|
] }),
|
|
1776
1917
|
/* @__PURE__ */ jsxs("div", { className: "rounded-card border bg-card shadow-sm divide-y", children: [
|
|
@@ -1816,10 +1957,7 @@ function StockCountSessionView({ sessionId, onBack }) {
|
|
|
1816
1957
|
{
|
|
1817
1958
|
open: confirmingClose,
|
|
1818
1959
|
title: t2("inventory.counts.closeTitle"),
|
|
1819
|
-
description: t2("inventory.counts.closeDescription", {
|
|
1820
|
-
divergent: String(summary.divergent),
|
|
1821
|
-
uncounted: String(summary.uncounted)
|
|
1822
|
-
}),
|
|
1960
|
+
description: t2("inventory.counts.closeDescription", { divergent: String(summary.divergent) }),
|
|
1823
1961
|
confirmLabel: t2("inventory.counts.closeConfirm"),
|
|
1824
1962
|
cancelLabel: t2("inventory.counts.cancel"),
|
|
1825
1963
|
loading: closing,
|
|
@@ -3159,22 +3297,36 @@ function buildNav(config, view, navigate, t2) {
|
|
|
3159
3297
|
icon: "Package",
|
|
3160
3298
|
active: view.startsWith("products"),
|
|
3161
3299
|
children: [
|
|
3162
|
-
{ id: "products-new", label: t2("inventory.nav.new"), active: view === "products-new", onClick: () => navigate("products-new") },
|
|
3300
|
+
{ id: "products-new", kind: "action", label: t2("inventory.nav.new"), active: view === "products-new", onClick: () => navigate("products-new") },
|
|
3163
3301
|
{ id: "products-list", label: t2("inventory.nav.list"), active: view === "products-list" || view.startsWith("products-detail:") || view.startsWith("products-stock:"), onClick: () => navigate("products-list") }
|
|
3164
3302
|
]
|
|
3165
3303
|
}] : [],
|
|
3304
|
+
// Movimentações é UMA tela: o que entrou e o que saiu. A pílula "Histórico"
|
|
3305
|
+
// ao lado da aba repetia o nome da própria aba (e o título da página já diz
|
|
3306
|
+
// "Histórico de Estoque"), então a aba passa a SER o destino — filha única
|
|
3307
|
+
// não desenha controle.
|
|
3166
3308
|
{
|
|
3167
3309
|
id: "stock",
|
|
3168
3310
|
label: t2("inventory.nav.stock"),
|
|
3169
3311
|
icon: "ArrowUpCircle",
|
|
3170
3312
|
children: [
|
|
3171
|
-
{ id: "stock-entry", label: t2("inventory.nav.entry"), active: view === "stock-entry", onClick: () => navigate("stock-entry") },
|
|
3172
|
-
{ id: "stock-exit", label: t2("inventory.nav.exit"), active: view === "stock-exit", onClick: () => navigate("stock-exit") },
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3313
|
+
{ id: "stock-entry", kind: "action", label: t2("inventory.nav.entry"), active: view === "stock-entry", onClick: () => navigate("stock-entry") },
|
|
3314
|
+
{ id: "stock-exit", kind: "action", label: t2("inventory.nav.exit"), active: view === "stock-exit", onClick: () => navigate("stock-exit") },
|
|
3315
|
+
{ id: "stock-history", label: t2("inventory.nav.history"), defaultChild: true, active: view === "stock-history", onClick: () => navigate("stock-history") }
|
|
3316
|
+
]
|
|
3317
|
+
},
|
|
3318
|
+
// Inventário — conferir o que o sistema diz contra o que existe na
|
|
3319
|
+
// prateleira. Contagem e auditoria de uso respondem à mesma pergunta por
|
|
3320
|
+
// dois caminhos (contar e comparar consumo), e nenhuma das duas é uma
|
|
3321
|
+
// movimentação: viviam como pílula de Movimentações e ninguém as achava.
|
|
3322
|
+
{
|
|
3323
|
+
id: "inventory-check",
|
|
3324
|
+
label: t2("inventory.nav.inventory"),
|
|
3325
|
+
icon: "ClipboardCheck",
|
|
3326
|
+
children: [
|
|
3327
|
+
{ id: "stock-counts-new", kind: "action", label: t2("inventory.nav.new"), active: view === "stock-counts-new", onClick: () => navigate("stock-counts-new") },
|
|
3328
|
+
{ id: "stock-counts", label: t2("inventory.nav.counts"), defaultChild: true, active: view === "stock-counts" || view.startsWith("stock-counts"), onClick: () => navigate("stock-counts") },
|
|
3329
|
+
{ id: "stock-usage-audit", label: t2("inventory.nav.usageAudit"), active: view === "stock-usage-audit", onClick: () => navigate("stock-usage-audit") }
|
|
3178
3330
|
]
|
|
3179
3331
|
}
|
|
3180
3332
|
];
|
|
@@ -3185,7 +3337,7 @@ function buildNav(config, view, navigate, t2) {
|
|
|
3185
3337
|
icon: "BookOpen",
|
|
3186
3338
|
children: [
|
|
3187
3339
|
{ id: "recipes-list", label: config.labels.recipesList, active: view === "recipes-list" || view.startsWith("recipes-detail:"), onClick: () => navigate("recipes-list") },
|
|
3188
|
-
{ id: "recipes-new", label: config.labels.recipesNew, active: view === "recipes-new", onClick: () => navigate("recipes-new") }
|
|
3340
|
+
{ id: "recipes-new", kind: "action", label: config.labels.recipesNew, active: view === "recipes-new", onClick: () => navigate("recipes-new") }
|
|
3189
3341
|
]
|
|
3190
3342
|
});
|
|
3191
3343
|
}
|
|
@@ -3389,6 +3541,120 @@ function RecentActivityPanel() {
|
|
|
3389
3541
|
] })
|
|
3390
3542
|
] });
|
|
3391
3543
|
}
|
|
3544
|
+
function NoMinimumKpi() {
|
|
3545
|
+
const summary = useInventoryStore((s) => s.summary);
|
|
3546
|
+
useEnsureSummary();
|
|
3547
|
+
const n = summary?.noMinimumCount ?? 0;
|
|
3548
|
+
return /* @__PURE__ */ jsx(
|
|
3549
|
+
KpiCard,
|
|
3550
|
+
{
|
|
3551
|
+
label: "Sem m\xEDnimo definido",
|
|
3552
|
+
icon: "AlertTriangle",
|
|
3553
|
+
value: String(n),
|
|
3554
|
+
sub: n > 0 ? "N\xE3o entram no alerta de estoque baixo" : "Todo produto tem m\xEDnimo"
|
|
3555
|
+
}
|
|
3556
|
+
);
|
|
3557
|
+
}
|
|
3558
|
+
function RestockRowSkeleton() {
|
|
3559
|
+
return /* @__PURE__ */ jsx("ul", { className: "space-y-2", children: [0, 1, 2].map((i) => /* @__PURE__ */ jsxs("li", { className: "flex items-center gap-3", children: [
|
|
3560
|
+
/* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-40" }),
|
|
3561
|
+
/* @__PURE__ */ jsx("span", { className: "flex-1" }),
|
|
3562
|
+
/* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-16" })
|
|
3563
|
+
] }, i)) });
|
|
3564
|
+
}
|
|
3565
|
+
function qty(value, unit) {
|
|
3566
|
+
const n = value.toLocaleString("pt-BR", { maximumFractionDigits: 3 });
|
|
3567
|
+
return unit ? `${n} ${unit}` : n;
|
|
3568
|
+
}
|
|
3569
|
+
function RestockPanel() {
|
|
3570
|
+
const rows = useInventoryStore((s) => s.restock);
|
|
3571
|
+
const summary = useInventoryStore((s) => s.summary);
|
|
3572
|
+
const fetchRestock = useInventoryStore((s) => s.fetchRestock);
|
|
3573
|
+
useEnsureSummary();
|
|
3574
|
+
useEffect(() => {
|
|
3575
|
+
void fetchRestock();
|
|
3576
|
+
}, []);
|
|
3577
|
+
const belowMinimum = (rows ?? []).filter((r) => r.reason === "below-minimum");
|
|
3578
|
+
const outOfStock = (rows ?? []).filter((r) => r.reason === "out-of-stock");
|
|
3579
|
+
const noMinimum = summary?.noMinimumCount ?? 0;
|
|
3580
|
+
const total = summary?.totalProducts ?? 0;
|
|
3581
|
+
const nobodyHasMinimum = total > 0 && noMinimum === total;
|
|
3582
|
+
return /* @__PURE__ */ jsxs(Card, { children: [
|
|
3583
|
+
/* @__PURE__ */ jsx(CardHeader, { children: /* @__PURE__ */ jsx(CardTitle, { children: "Precisa de reposi\xE7\xE3o" }) }),
|
|
3584
|
+
/* @__PURE__ */ jsx(CardContent, { children: rows === null ? /* @__PURE__ */ jsx(RestockRowSkeleton, {}) : nobodyHasMinimum ? (
|
|
3585
|
+
// Sem nenhum mínimo cadastrado esta lista não é um alerta: é o
|
|
3586
|
+
// catálogo inteiro. Dizer o que falta vale mais do que listá-lo.
|
|
3587
|
+
/* @__PURE__ */ jsxs("div", { className: "text-sm", children: [
|
|
3588
|
+
/* @__PURE__ */ jsx("p", { className: "font-medium", children: "Nenhum produto tem estoque m\xEDnimo definido." }),
|
|
3589
|
+
/* @__PURE__ */ jsxs("p", { className: "mt-1 text-muted-foreground", children: [
|
|
3590
|
+
"Sem o m\xEDnimo na ficha do produto n\xE3o h\xE1 reposi\xE7\xE3o a calcular \u2014 o alerta de estoque baixo fica em zero mesmo com o dep\xF3sito vazio.",
|
|
3591
|
+
(summary?.outOfStockCount ?? 0) > 0 && ` Hoje ${summary.outOfStockCount === 1 ? "h\xE1 1 produto" : `h\xE1 ${summary.outOfStockCount} produtos`} com saldo zerado.`
|
|
3592
|
+
] })
|
|
3593
|
+
] })
|
|
3594
|
+
) : rows.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "Nada a repor: todo produto est\xE1 acima do m\xEDnimo." }) : /* @__PURE__ */ jsx("ul", { className: "space-y-2", children: [...belowMinimum, ...outOfStock].slice(0, 8).map((row) => /* @__PURE__ */ jsxs("li", { className: "flex items-center gap-3 text-sm", children: [
|
|
3595
|
+
/* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
|
|
3596
|
+
/* @__PURE__ */ jsx("span", { className: "block truncate", children: row.name }),
|
|
3597
|
+
row.categoryName && /* @__PURE__ */ jsx("span", { className: "block truncate text-xs text-muted-foreground", children: row.categoryName })
|
|
3598
|
+
] }),
|
|
3599
|
+
row.reason === "below-minimum" ? /* @__PURE__ */ jsxs(Badge, { variant: "secondary", className: "gap-1 shrink-0", children: [
|
|
3600
|
+
/* @__PURE__ */ jsx(AlertTriangle, { className: "h-3 w-3", "aria-hidden": "true" }),
|
|
3601
|
+
qty(row.currentQuantity, row.unit),
|
|
3602
|
+
" de ",
|
|
3603
|
+
qty(row.minQuantity, row.unit)
|
|
3604
|
+
] }) : /* @__PURE__ */ jsxs(Badge, { variant: "outline", className: "gap-1 shrink-0", children: [
|
|
3605
|
+
/* @__PURE__ */ jsx(PackageX, { className: "h-3 w-3", "aria-hidden": "true" }),
|
|
3606
|
+
"Sem estoque"
|
|
3607
|
+
] })
|
|
3608
|
+
] }, row.productId)) }) })
|
|
3609
|
+
] });
|
|
3610
|
+
}
|
|
3611
|
+
function CategoryMixPanel() {
|
|
3612
|
+
const mix = useInventoryStore((s) => s.categoryMix);
|
|
3613
|
+
const fetchMix = useInventoryStore((s) => s.fetchCategoryMix);
|
|
3614
|
+
useEffect(() => {
|
|
3615
|
+
void fetchMix();
|
|
3616
|
+
}, []);
|
|
3617
|
+
const total = (mix ?? []).reduce((sum, c) => sum + c.count, 0);
|
|
3618
|
+
const uncategorized = (mix ?? []).find((c) => c.categoryId === null);
|
|
3619
|
+
return /* @__PURE__ */ jsxs(Card, { children: [
|
|
3620
|
+
/* @__PURE__ */ jsx(CardHeader, { children: /* @__PURE__ */ jsx(CardTitle, { children: "Produtos por categoria" }) }),
|
|
3621
|
+
/* @__PURE__ */ jsx(CardContent, { children: mix === null ? /* @__PURE__ */ jsx(RestockRowSkeleton, {}) : total === 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "Nenhum produto ativo para distribuir." }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
3622
|
+
/* @__PURE__ */ jsx("ul", { className: "space-y-2", children: mix.slice(0, 8).map((row) => {
|
|
3623
|
+
const pct = Math.round(row.count / total * 100);
|
|
3624
|
+
const isGap = row.categoryId === null;
|
|
3625
|
+
return /* @__PURE__ */ jsxs("li", { className: "flex items-center gap-3 text-sm", children: [
|
|
3626
|
+
/* @__PURE__ */ jsx("span", { className: "w-40 shrink-0 truncate", title: row.name, children: row.name }),
|
|
3627
|
+
/* @__PURE__ */ jsx(
|
|
3628
|
+
"span",
|
|
3629
|
+
{
|
|
3630
|
+
className: "relative h-2 flex-1 overflow-hidden rounded-full bg-muted",
|
|
3631
|
+
"aria-hidden": "true",
|
|
3632
|
+
children: /* @__PURE__ */ jsx(
|
|
3633
|
+
"span",
|
|
3634
|
+
{
|
|
3635
|
+
className: isGap ? "absolute inset-y-0 left-0 rounded-full bg-muted-foreground/60" : "absolute inset-y-0 left-0 rounded-full bg-primary",
|
|
3636
|
+
style: { width: `${pct}%` }
|
|
3637
|
+
}
|
|
3638
|
+
)
|
|
3639
|
+
}
|
|
3640
|
+
),
|
|
3641
|
+
/* @__PURE__ */ jsxs("span", { className: "w-20 shrink-0 text-right tabular-nums text-muted-foreground", children: [
|
|
3642
|
+
row.count,
|
|
3643
|
+
" \xB7 ",
|
|
3644
|
+
pct,
|
|
3645
|
+
"%"
|
|
3646
|
+
] })
|
|
3647
|
+
] }, row.categoryId ?? "none");
|
|
3648
|
+
}) }),
|
|
3649
|
+
uncategorized && uncategorized.count > 0 && /* @__PURE__ */ jsxs("p", { className: "mt-3 text-xs text-muted-foreground", children: [
|
|
3650
|
+
uncategorized.count,
|
|
3651
|
+
" de ",
|
|
3652
|
+
total,
|
|
3653
|
+
" produtos ainda n\xE3o t\xEAm categoria \u2014 \xE9 o que impede este painel, os filtros e as isen\xE7\xF5es de taxa de dizerem algo \xFAtil."
|
|
3654
|
+
] })
|
|
3655
|
+
] }) })
|
|
3656
|
+
] });
|
|
3657
|
+
}
|
|
3392
3658
|
function createInventoryDashboardWidgets(ctx2) {
|
|
3393
3659
|
const withCtx = (Inner) => {
|
|
3394
3660
|
const Wrapped = () => /* @__PURE__ */ jsx(InventoryContextProvider, { config: ctx2.config, provider: ctx2.provider, store: ctx2.store, children: /* @__PURE__ */ jsx(Inner, {}) });
|
|
@@ -3402,7 +3668,12 @@ function createInventoryDashboardWidgets(ctx2) {
|
|
|
3402
3668
|
defineKpiWidget({ id: "inventory.kpi.total-products", title: "inventory.dashboard.totalProducts", domain: "inventory", defaultOrder: 1, defaultVisible: false, component: withCtx(TotalProductsKpi) }),
|
|
3403
3669
|
defineKpiWidget({ id: "inventory.kpi.low-stock", title: "inventory.dashboard.lowStock", domain: "inventory", defaultOrder: 2, defaultVisible: false, component: withCtx(LowStockKpi) }),
|
|
3404
3670
|
defineKpiWidget({ id: "inventory.kpi.out-of-stock", title: "inventory.dashboard.outOfStock", domain: "inventory", defaultOrder: 3, defaultVisible: false, component: withCtx(OutOfStockKpi) }),
|
|
3405
|
-
|
|
3671
|
+
// Escondido na home global (é diagnóstico de cadastro, não manchete do
|
|
3672
|
+
// negócio) e visível no painel do estoque, que é onde se conserta.
|
|
3673
|
+
defineKpiWidget({ id: "inventory.kpi.no-minimum", title: "Sem m\xEDnimo definido", domain: "inventory", defaultOrder: 4, defaultVisible: false, component: withCtx(NoMinimumKpi) }),
|
|
3674
|
+
defineCustomWidget({ id: "inventory.panel.recent-activity", title: "inventory.dashboard.recentActivity", domain: "inventory", span: 2, defaultOrder: 10, surfaces: ["plugin-home"], component: withCtx(RecentActivityPanel) }),
|
|
3675
|
+
defineCustomWidget({ id: "inventory.panel.restock", title: "Precisa de reposi\xE7\xE3o", domain: "inventory", span: 2, defaultOrder: 11, surfaces: ["plugin-home"], component: withCtx(RestockPanel) }),
|
|
3676
|
+
defineCustomWidget({ id: "inventory.panel.category-mix", title: "Produtos por categoria", domain: "inventory", span: 2, defaultOrder: 12, surfaces: ["plugin-home"], component: withCtx(CategoryMixPanel) })
|
|
3406
3677
|
];
|
|
3407
3678
|
}
|
|
3408
3679
|
|
|
@@ -3445,6 +3716,7 @@ function createStore() {
|
|
|
3445
3716
|
function createMockInventoryProvider() {
|
|
3446
3717
|
const store = createStore();
|
|
3447
3718
|
const tenantId = "mock-tenant";
|
|
3719
|
+
const countMovementWatermarks = /* @__PURE__ */ new Map();
|
|
3448
3720
|
function withProduct(recipe) {
|
|
3449
3721
|
const product = store.products.find((p) => p.id === recipe.productId);
|
|
3450
3722
|
return { ...recipe, productName: product?.name ?? recipe.productName, productSalePrice: product?.salePrice };
|
|
@@ -3732,6 +4004,7 @@ function createMockInventoryProvider() {
|
|
|
3732
4004
|
updatedAt: now()
|
|
3733
4005
|
};
|
|
3734
4006
|
store.countSessions.push(session);
|
|
4007
|
+
countMovementWatermarks.set(session.id, new Set(store.movements.map((movement) => movement.id)));
|
|
3735
4008
|
for (const product of store.products) {
|
|
3736
4009
|
if (!product.isActive || product.productType === "asset") continue;
|
|
3737
4010
|
if (input.categoryId && product.categoryId !== input.categoryId) continue;
|
|
@@ -3771,20 +4044,37 @@ function createMockInventoryProvider() {
|
|
|
3771
4044
|
return { sessionId, adjustmentsCreated: 0, alreadyClosed: true };
|
|
3772
4045
|
}
|
|
3773
4046
|
if (session.status === "cancelled") throw new Error("This count was cancelled");
|
|
4047
|
+
const sessionItems = store.countItems.filter((item) => item.sessionId === sessionId);
|
|
4048
|
+
const uncounted = sessionItems.filter((item) => item.countedQuantity === void 0);
|
|
4049
|
+
if (uncounted.length > 0) {
|
|
4050
|
+
throw new Error(`Count every line before closing (${uncounted.length} uncounted)`);
|
|
4051
|
+
}
|
|
4052
|
+
const movementWatermark = countMovementWatermarks.get(sessionId) ?? /* @__PURE__ */ new Set();
|
|
4053
|
+
const changedAfterOpening = store.movements.some(
|
|
4054
|
+
(movement) => !movementWatermark.has(movement.id) && sessionItems.some((item) => item.productId === movement.productId) && (movement.stockLocationId === session.stockLocationId || movement.destinationLocationId === session.stockLocationId)
|
|
4055
|
+
);
|
|
4056
|
+
if (changedAfterOpening) {
|
|
4057
|
+
throw new Error("Stock changed after this count opened; review and recount");
|
|
4058
|
+
}
|
|
3774
4059
|
let emitted = 0;
|
|
3775
|
-
for (const item of
|
|
3776
|
-
if (item.sessionId !== sessionId) continue;
|
|
4060
|
+
for (const item of sessionItems) {
|
|
3777
4061
|
if (!isDivergent(item) || item.movementId) continue;
|
|
3778
4062
|
const counted = item.countedQuantity;
|
|
3779
4063
|
const delta = counted - item.systemQuantity;
|
|
4064
|
+
const hasBatchStock = store.positions.some(
|
|
4065
|
+
(position) => position.productId === item.productId && position.stockLocationId === session.stockLocationId && position.quantity !== 0 && Boolean(position.batchNumber || position.expirationDate)
|
|
4066
|
+
);
|
|
4067
|
+
if (hasBatchStock) {
|
|
4068
|
+
throw new Error(`Product ${item.productId} has batch stock; use a batch-aware count`);
|
|
4069
|
+
}
|
|
3780
4070
|
const movement = {
|
|
3781
4071
|
id: uid(),
|
|
3782
4072
|
productId: item.productId,
|
|
3783
4073
|
productName: item.productName,
|
|
3784
|
-
quantity:
|
|
4074
|
+
quantity: delta,
|
|
3785
4075
|
movementType: "adjustment",
|
|
3786
4076
|
unitCost: item.unitCost,
|
|
3787
|
-
totalCost: item.unitCost *
|
|
4077
|
+
totalCost: item.unitCost * Math.abs(delta),
|
|
3788
4078
|
stockLocationId: session.stockLocationId,
|
|
3789
4079
|
stockLocationName: session.stockLocationName,
|
|
3790
4080
|
reason: reason || "Stock count",
|
|
@@ -3813,6 +4103,7 @@ function createMockInventoryProvider() {
|
|
|
3813
4103
|
session.status = "closed";
|
|
3814
4104
|
session.closedAt = now();
|
|
3815
4105
|
session.updatedAt = now();
|
|
4106
|
+
countMovementWatermarks.delete(sessionId);
|
|
3816
4107
|
return { sessionId, adjustmentsCreated: emitted, alreadyClosed: false };
|
|
3817
4108
|
},
|
|
3818
4109
|
async cancelCountSession(sessionId) {
|
|
@@ -3821,6 +4112,7 @@ function createMockInventoryProvider() {
|
|
|
3821
4112
|
if (session.status === "closed") throw new Error("A closed count cannot be cancelled");
|
|
3822
4113
|
session.status = "cancelled";
|
|
3823
4114
|
session.updatedAt = now();
|
|
4115
|
+
countMovementWatermarks.delete(sessionId);
|
|
3824
4116
|
return session;
|
|
3825
4117
|
},
|
|
3826
4118
|
// --- Recipes ---
|
|
@@ -4024,7 +4316,8 @@ function createMockInventoryProvider() {
|
|
|
4024
4316
|
outOfStockCount: outOfStock.length,
|
|
4025
4317
|
totalStockValue: totalValue,
|
|
4026
4318
|
recentMovementCount: recent.length,
|
|
4027
|
-
movementsByType
|
|
4319
|
+
movementsByType,
|
|
4320
|
+
noMinimumCount: active.filter((p) => !(p.minQuantity > 0)).length
|
|
4028
4321
|
};
|
|
4029
4322
|
}
|
|
4030
4323
|
};
|
|
@@ -4563,6 +4856,7 @@ function createSupabaseInventoryProvider() {
|
|
|
4563
4856
|
if (!locationId) {
|
|
4564
4857
|
throw new Error("inventory: this tenant has no stock location to record the movement against");
|
|
4565
4858
|
}
|
|
4859
|
+
const idempotencyKey = input.idempotencyKey ?? createIdempotencyKey(`inventory:${kind}`);
|
|
4566
4860
|
const { data: opResult, error } = kind === "in" ? await pub.rpc("inventory_receive", {
|
|
4567
4861
|
p_location: locationId,
|
|
4568
4862
|
p_lines: [line],
|
|
@@ -4571,32 +4865,37 @@ function createSupabaseInventoryProvider() {
|
|
|
4571
4865
|
...input.documentNumber ? { ref: input.documentNumber } : {},
|
|
4572
4866
|
...input.supplierId ? { supplier_id: input.supplierId } : {},
|
|
4573
4867
|
...input.movementDate ? { date: input.movementDate } : {}
|
|
4574
|
-
}
|
|
4868
|
+
},
|
|
4869
|
+
p_idempotency_key: idempotencyKey
|
|
4575
4870
|
}) : await pub.rpc("inventory_adjust", {
|
|
4576
4871
|
p_location: locationId,
|
|
4577
4872
|
p_lines: [line],
|
|
4578
4873
|
// The ledger refuses an adjustment with no reason, and the verb is one
|
|
4579
4874
|
// when the user did not type another.
|
|
4580
|
-
p_reason: input.reason ?? input.movementType
|
|
4875
|
+
p_reason: input.reason ?? input.movementType,
|
|
4876
|
+
p_idempotency_key: idempotencyKey
|
|
4581
4877
|
});
|
|
4582
|
-
if (error) throw
|
|
4878
|
+
if (error) throw error;
|
|
4583
4879
|
const written = opResult?.movements?.[0];
|
|
4584
4880
|
if (!written) throw new Error("inventory: the ledger accepted the operation but returned no movement");
|
|
4585
4881
|
const data = { ...written, tenant_id: tenantId, movement_type: input.movementType };
|
|
4586
|
-
const { data: product } = await core.from(PRODUCTS_READ).select("id, name, stock").eq("id", input.productId).single();
|
|
4587
4882
|
const movement = snakeToCamel(data);
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
|
|
4591
|
-
|
|
4592
|
-
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
|
|
4883
|
+
try {
|
|
4884
|
+
const { data: product } = await core.from(PRODUCTS_READ).select("id, name, stock").eq("id", input.productId).single();
|
|
4885
|
+
movement.productName = product?.name;
|
|
4886
|
+
if (input.stockLocationId) {
|
|
4887
|
+
const { data: loc } = await pub.from(T.stockLocations).select("name").eq("id", input.stockLocationId).single();
|
|
4888
|
+
movement.stockLocationName = loc?.name;
|
|
4889
|
+
}
|
|
4890
|
+
if (input.destinationLocationId) {
|
|
4891
|
+
const { data: loc } = await pub.from(T.stockLocations).select("name").eq("id", input.destinationLocationId).single();
|
|
4892
|
+
movement.destinationLocationName = loc?.name;
|
|
4893
|
+
}
|
|
4894
|
+
if (input.supplierId) {
|
|
4895
|
+
const { data: supplier } = await core.from("people").select("name").eq("id", input.supplierId).single();
|
|
4896
|
+
movement.supplierName = supplier?.name;
|
|
4897
|
+
}
|
|
4898
|
+
} catch {
|
|
4600
4899
|
}
|
|
4601
4900
|
return movement;
|
|
4602
4901
|
},
|
|
@@ -5060,8 +5359,58 @@ function createSupabaseInventoryProvider() {
|
|
|
5060
5359
|
outOfStockCount: outOfStock.length,
|
|
5061
5360
|
totalStockValue: totalValue,
|
|
5062
5361
|
recentMovementCount: movs.length,
|
|
5063
|
-
movementsByType
|
|
5362
|
+
movementsByType,
|
|
5363
|
+
// `> 0`, não `!= null`: mínimo zero é o mesmo que não ter mínimo — é
|
|
5364
|
+
// dele que sai o alerta que nunca dispara.
|
|
5365
|
+
noMinimumCount: items.filter((p) => !(Number(p.min_stock) > 0)).length
|
|
5064
5366
|
};
|
|
5367
|
+
},
|
|
5368
|
+
async getRestockList(limit = 50) {
|
|
5369
|
+
const { core, pub } = getClients();
|
|
5370
|
+
const { data } = await core.from(PRODUCTS_READ).select("id, name, category_id, stock, min_stock, unit").eq("is_active", true);
|
|
5371
|
+
const rows = data ?? [];
|
|
5372
|
+
const needs = rows.map((r) => ({
|
|
5373
|
+
productId: r.id,
|
|
5374
|
+
name: r.name,
|
|
5375
|
+
categoryId: r.category_id,
|
|
5376
|
+
currentQuantity: Number(r.stock) || 0,
|
|
5377
|
+
minQuantity: Number(r.min_stock) || 0,
|
|
5378
|
+
unit: r.unit ?? void 0
|
|
5379
|
+
})).filter((r) => r.currentQuantity <= 0 || r.minQuantity > 0 && r.currentQuantity <= r.minQuantity).sort((a, b) => {
|
|
5380
|
+
const rank = (x) => x.minQuantity > 0 && x.currentQuantity > 0 ? 0 : 1;
|
|
5381
|
+
return rank(a) - rank(b) || a.name.localeCompare(b.name, "pt-BR");
|
|
5382
|
+
}).slice(0, limit);
|
|
5383
|
+
const catIds = [...new Set(needs.map((n) => n.categoryId).filter(Boolean))];
|
|
5384
|
+
const names = /* @__PURE__ */ new Map();
|
|
5385
|
+
if (catIds.length) {
|
|
5386
|
+
const { data: cats } = await pub.from("categories").select("id, name").in("id", catIds);
|
|
5387
|
+
for (const c of cats ?? []) names.set(c.id, c.name);
|
|
5388
|
+
}
|
|
5389
|
+
return needs.map(({ categoryId, ...rest }) => ({
|
|
5390
|
+
...rest,
|
|
5391
|
+
categoryName: categoryId ? names.get(categoryId) : void 0,
|
|
5392
|
+
reason: rest.currentQuantity <= 0 ? "out-of-stock" : "below-minimum"
|
|
5393
|
+
}));
|
|
5394
|
+
},
|
|
5395
|
+
async getCategoryDistribution() {
|
|
5396
|
+
const { core, pub } = getClients();
|
|
5397
|
+
const { data } = await core.from(PRODUCTS_READ).select("category_id").eq("is_active", true);
|
|
5398
|
+
const rows = data ?? [];
|
|
5399
|
+
const tally = /* @__PURE__ */ new Map();
|
|
5400
|
+
for (const r of rows) tally.set(r.category_id ?? null, (tally.get(r.category_id ?? null) ?? 0) + 1);
|
|
5401
|
+
const ids = [...tally.keys()].filter(Boolean);
|
|
5402
|
+
const names = /* @__PURE__ */ new Map();
|
|
5403
|
+
if (ids.length) {
|
|
5404
|
+
const { data: cats } = await pub.from("categories").select("id, name").in("id", ids);
|
|
5405
|
+
for (const c of cats ?? []) names.set(c.id, c.name);
|
|
5406
|
+
}
|
|
5407
|
+
return [...tally.entries()].map(([categoryId, count]) => ({
|
|
5408
|
+
categoryId,
|
|
5409
|
+
// "Sem categoria" é uma resposta, não um buraco: é o que diz ao dono
|
|
5410
|
+
// que o cadastro está incompleto. Esconder o balde esconderia isso.
|
|
5411
|
+
name: categoryId ? names.get(categoryId) ?? "Categoria removida" : "Sem categoria",
|
|
5412
|
+
count
|
|
5413
|
+
})).sort((a, b) => b.count - a.count);
|
|
5065
5414
|
}
|
|
5066
5415
|
};
|
|
5067
5416
|
return provider;
|
|
@@ -5085,6 +5434,28 @@ function createInventoryStore(provider) {
|
|
|
5085
5434
|
recipesLoading: false,
|
|
5086
5435
|
summary: null,
|
|
5087
5436
|
summaryLoading: false,
|
|
5437
|
+
restock: null,
|
|
5438
|
+
categoryMix: null,
|
|
5439
|
+
async fetchRestock() {
|
|
5440
|
+
if (!provider.getRestockList) return;
|
|
5441
|
+
return dedup("inv:restock", async () => {
|
|
5442
|
+
try {
|
|
5443
|
+
set({ restock: await provider.getRestockList() });
|
|
5444
|
+
} catch {
|
|
5445
|
+
set({ restock: [] });
|
|
5446
|
+
}
|
|
5447
|
+
});
|
|
5448
|
+
},
|
|
5449
|
+
async fetchCategoryMix() {
|
|
5450
|
+
if (!provider.getCategoryDistribution) return;
|
|
5451
|
+
return dedup("inv:category-mix", async () => {
|
|
5452
|
+
try {
|
|
5453
|
+
set({ categoryMix: await provider.getCategoryDistribution() });
|
|
5454
|
+
} catch {
|
|
5455
|
+
set({ categoryMix: [] });
|
|
5456
|
+
}
|
|
5457
|
+
});
|
|
5458
|
+
},
|
|
5088
5459
|
async fetchSummary() {
|
|
5089
5460
|
return dedup("inv:summary", async () => {
|
|
5090
5461
|
set({ summaryLoading: true });
|
|
@@ -5162,16 +5533,20 @@ function createInventoryStore(provider) {
|
|
|
5162
5533
|
}
|
|
5163
5534
|
},
|
|
5164
5535
|
async createMovement(input) {
|
|
5536
|
+
let movement;
|
|
5165
5537
|
try {
|
|
5166
|
-
|
|
5167
|
-
const [summary] = await Promise.all([provider.getSummary()]);
|
|
5168
|
-
set({ summary });
|
|
5169
|
-
toast.success("Stock movement recorded");
|
|
5170
|
-
return movement;
|
|
5538
|
+
movement = await provider.createMovement(input);
|
|
5171
5539
|
} catch (err) {
|
|
5172
5540
|
toast.error("Failed to record movement", { description: err?.message });
|
|
5173
5541
|
throw err;
|
|
5174
5542
|
}
|
|
5543
|
+
try {
|
|
5544
|
+
set({ summary: await provider.getSummary() });
|
|
5545
|
+
toast.success("Stock movement recorded");
|
|
5546
|
+
} catch {
|
|
5547
|
+
toast.warning("Stock movement recorded; refresh the list to see the updated balance");
|
|
5548
|
+
}
|
|
5549
|
+
return movement;
|
|
5175
5550
|
},
|
|
5176
5551
|
async createRecipe(input) {
|
|
5177
5552
|
try {
|
|
@@ -5350,8 +5725,12 @@ var products = {
|
|
|
5350
5725
|
check: "existe pelo menos um local \u2014 \xE9 onde o saldo vai morar"
|
|
5351
5726
|
}
|
|
5352
5727
|
],
|
|
5728
|
+
// O tour navega até o cadastro de produto; os passos sem âncora ficam de
|
|
5729
|
+
// briefing (a tela é um CRUD genérico, sem campos ancoráveis por enquanto).
|
|
5353
5730
|
steps: [
|
|
5354
|
-
{ do: "Estoque
|
|
5731
|
+
{ do: "O Estoque mora aqui.", anchor: "nav./inventory", route: "/inventory" },
|
|
5732
|
+
{ do: "Produtos ficam neste menu.", anchor: "modnav.products", route: "/inventory/products/list" },
|
|
5733
|
+
{ do: "Cadastre um produto novo aqui.", anchor: "action.new-product", route: "/inventory/products/list" },
|
|
5355
5734
|
{
|
|
5356
5735
|
do: "Escolha a unidade em que voc\xEA CONTA o produto, n\xE3o a que o fornecedor vende.",
|
|
5357
5736
|
why: "Se voc\xEA conta ampolas e ele vende caixa, o produto \xE9 em ampola \u2014 a convers\xE3o \xE9 problema da entrada."
|
|
@@ -5389,10 +5768,16 @@ var movements = {
|
|
|
5389
5768
|
check: "o movimento pede um local e existe op\xE7\xE3o"
|
|
5390
5769
|
}
|
|
5391
5770
|
],
|
|
5771
|
+
// Navega até a tela de entrada; a `practice` deixa a pessoa dar a entrada.
|
|
5392
5772
|
steps: [
|
|
5393
|
-
{ do: "Estoque
|
|
5394
|
-
{ do: "
|
|
5395
|
-
{ do: "
|
|
5773
|
+
{ do: "O Estoque mora aqui.", anchor: "nav./inventory", route: "/inventory" },
|
|
5774
|
+
{ do: "Abra Entrada neste menu para lan\xE7ar a compra que chegou.", anchor: "modnav.stock-entry", route: "/inventory/stock/entry" },
|
|
5775
|
+
{ do: "Sa\xEDda \xE9 para o que foi descartado, perdido ou usado fora de um atendimento." },
|
|
5776
|
+
{ do: "O hist\xF3rico mostra tudo, inclusive o que o sistema lan\xE7ou sozinho." }
|
|
5777
|
+
],
|
|
5778
|
+
practice: [
|
|
5779
|
+
{ anchor: "inventory.entry-product", label: "Escolha o produto que chegou" },
|
|
5780
|
+
{ anchor: "inventory.entry-save", label: "Confirme a entrada", final: true }
|
|
5396
5781
|
],
|
|
5397
5782
|
rules: [
|
|
5398
5783
|
"O raz\xE3o \xE9 append-only: nada \xE9 apagado. Corrigir \xE9 lan\xE7ar o movimento contr\xE1rio \u2014 e o hist\xF3rico guarda os dois.",
|
|
@@ -5570,6 +5955,8 @@ function buildInventoryOnboarding() {
|
|
|
5570
5955
|
|
|
5571
5956
|
// src/locales/en.ts
|
|
5572
5957
|
var en = {
|
|
5958
|
+
"inventory.stock.pendingRetry": "Confirmation is pending. Do not create another entry. Retry the same details before leaving this screen.",
|
|
5959
|
+
"inventory.stock.refreshAfterCommit": "Movement recorded. Refresh the screen to see the balance.",
|
|
5573
5960
|
"inventory.dashboard.activeItems": "Active items",
|
|
5574
5961
|
"inventory.dashboard.belowMinimum": "Below minimum",
|
|
5575
5962
|
"inventory.dashboard.entries": "Entries:",
|
|
@@ -5609,10 +5996,11 @@ var en = {
|
|
|
5609
5996
|
"inventory.nav.entry": "Entry",
|
|
5610
5997
|
"inventory.nav.exit": "Exit",
|
|
5611
5998
|
"inventory.nav.history": "History",
|
|
5999
|
+
"inventory.nav.inventory": "Inventory",
|
|
5612
6000
|
"inventory.nav.list": "List",
|
|
5613
6001
|
"inventory.nav.new": "New",
|
|
5614
6002
|
"inventory.nav.products": "Products",
|
|
5615
|
-
"inventory.nav.stock": "
|
|
6003
|
+
"inventory.nav.stock": "Movements",
|
|
5616
6004
|
"inventory.page.newProduct": "New Product",
|
|
5617
6005
|
"inventory.page.newProductDesc": "Add a product to your catalog",
|
|
5618
6006
|
"inventory.page.settingsSubtitle": "Preferences, suppliers, categories, and units",
|
|
@@ -5957,7 +6345,9 @@ var en = {
|
|
|
5957
6345
|
"inventory.counts.saveFailed": "Could not save this line",
|
|
5958
6346
|
"inventory.counts.close": "Close the count",
|
|
5959
6347
|
"inventory.counts.closeTitle": "Close the count?",
|
|
5960
|
-
"inventory.counts.closeDescription": "{{divergent}} line(s) differ from the system and will get an adjustment.
|
|
6348
|
+
"inventory.counts.closeDescription": "{{divergent}} line(s) differ from the system and will get an adjustment.",
|
|
6349
|
+
"inventory.counts.incomplete": "Finish the count before closing",
|
|
6350
|
+
"inventory.counts.incompleteDescription": "{{count}} line(s) are still blank. Enter zero when the item is physically out of stock.",
|
|
5961
6351
|
"inventory.counts.closeConfirm": "Close and adjust",
|
|
5962
6352
|
"inventory.counts.closed": "Count closed \u2014 {{count}} adjustment(s) recorded",
|
|
5963
6353
|
"inventory.counts.alreadyClosed": "This count was already closed",
|
|
@@ -6018,6 +6408,8 @@ var en = {
|
|
|
6018
6408
|
|
|
6019
6409
|
// src/locales/pt-BR.ts
|
|
6020
6410
|
var ptBR = {
|
|
6411
|
+
"inventory.stock.pendingRetry": "A confirma\xE7\xE3o ainda est\xE1 pendente. N\xE3o fa\xE7a outro lan\xE7amento. Tente novamente com os mesmos dados antes de sair desta tela.",
|
|
6412
|
+
"inventory.stock.refreshAfterCommit": "Movimento registrado. Atualize a tela para consultar o saldo.",
|
|
6021
6413
|
"inventory.dashboard.activeItems": "Itens ativos",
|
|
6022
6414
|
"inventory.dashboard.belowMinimum": "Abaixo do m\xEDnimo",
|
|
6023
6415
|
"inventory.dashboard.entries": "Entradas:",
|
|
@@ -6057,10 +6449,11 @@ var ptBR = {
|
|
|
6057
6449
|
"inventory.nav.entry": "Entrada",
|
|
6058
6450
|
"inventory.nav.exit": "Sa\xEDda",
|
|
6059
6451
|
"inventory.nav.history": "Hist\xF3rico",
|
|
6452
|
+
"inventory.nav.inventory": "Invent\xE1rio",
|
|
6060
6453
|
"inventory.nav.list": "Lista",
|
|
6061
6454
|
"inventory.nav.new": "Novo",
|
|
6062
6455
|
"inventory.nav.products": "Produtos",
|
|
6063
|
-
"inventory.nav.stock": "
|
|
6456
|
+
"inventory.nav.stock": "Movimenta\xE7\xF5es",
|
|
6064
6457
|
"inventory.page.newProduct": "Novo Produto",
|
|
6065
6458
|
"inventory.page.newProductDesc": "Adicionar um produto ao seu cat\xE1logo",
|
|
6066
6459
|
"inventory.page.settingsSubtitle": "Prefer\xEAncias, fornecedores, categorias e unidades",
|
|
@@ -6405,7 +6798,9 @@ var ptBR = {
|
|
|
6405
6798
|
"inventory.counts.saveFailed": "N\xE3o foi poss\xEDvel salvar esta linha",
|
|
6406
6799
|
"inventory.counts.close": "Fechar a contagem",
|
|
6407
6800
|
"inventory.counts.closeTitle": "Fechar a contagem?",
|
|
6408
|
-
"inventory.counts.closeDescription": "{{divergent}} linha(s) divergem do sistema e v\xE3o receber um ajuste.
|
|
6801
|
+
"inventory.counts.closeDescription": "{{divergent}} linha(s) divergem do sistema e v\xE3o receber um ajuste.",
|
|
6802
|
+
"inventory.counts.incomplete": "Finalize a contagem antes de fechar",
|
|
6803
|
+
"inventory.counts.incompleteDescription": "Ainda existem {{count}} linha(s) em branco. Informe zero quando o item acabou fisicamente.",
|
|
6409
6804
|
"inventory.counts.closeConfirm": "Fechar e ajustar",
|
|
6410
6805
|
"inventory.counts.closed": "Contagem fechada \u2014 {{count}} ajuste(s) lan\xE7ado(s)",
|
|
6411
6806
|
"inventory.counts.alreadyClosed": "Esta contagem j\xE1 estava fechada",
|
|
@@ -10584,10 +10979,175 @@ REVOKE ALL ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) FROM PU
|
|
|
10584
10979
|
GRANT ALL ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) TO authenticated;
|
|
10585
10980
|
GRANT ALL ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) TO service_role;
|
|
10586
10981
|
`;
|
|
10982
|
+
var MIGRATION_003_STOCK_COUNT_CLOSES_THROUGH_LEDGER = `-- 003_stock_count_closes_through_ledger.sql
|
|
10983
|
+
--
|
|
10984
|
+
-- A physical count is a reconciliation, not a second stock writer. The old
|
|
10985
|
+
-- function inserted values into GENERATED columns, recorded the counted total
|
|
10986
|
+
-- instead of the variance and then updated the position a second time outside
|
|
10987
|
+
-- the ledger trigger. This replacement emits one canonical \`adjust\` movement
|
|
10988
|
+
-- per divergent line and lets the existing movement trigger update the balance.
|
|
10989
|
+
--
|
|
10990
|
+
-- Safety gates: every line must be counted (zero is valid); any stock movement
|
|
10991
|
+
-- after the opening snapshot aborts the close; lot stock requires a future
|
|
10992
|
+
-- batch-aware count instead of guessing which lot changed.
|
|
10993
|
+
|
|
10994
|
+
CREATE OR REPLACE FUNCTION public.inventory_close_count_session(p_session_id uuid, p_reason text DEFAULT NULL::text) RETURNS jsonb
|
|
10995
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
10996
|
+
SET search_path TO ''
|
|
10997
|
+
AS $$
|
|
10998
|
+
DECLARE
|
|
10999
|
+
v_tenant uuid := app.inventory_require_tenant();
|
|
11000
|
+
v_session public.plg_inventory_count_sessions%ROWTYPE;
|
|
11001
|
+
v_item public.plg_inventory_count_items%ROWTYPE;
|
|
11002
|
+
v_op record;
|
|
11003
|
+
v_delta numeric;
|
|
11004
|
+
v_current numeric;
|
|
11005
|
+
v_current_cost numeric;
|
|
11006
|
+
v_has_lots boolean;
|
|
11007
|
+
v_movement_id uuid;
|
|
11008
|
+
v_reason text;
|
|
11009
|
+
v_key text := 'count:' || p_session_id::text || ':close:v1';
|
|
11010
|
+
v_line_no int := 0;
|
|
11011
|
+
v_uncounted int;
|
|
11012
|
+
v_conflicts int;
|
|
11013
|
+
v_emitted int := 0;
|
|
11014
|
+
v_movements jsonb := '[]'::jsonb;
|
|
11015
|
+
v_positions jsonb := '[]'::jsonb;
|
|
11016
|
+
v_result jsonb;
|
|
11017
|
+
BEGIN
|
|
11018
|
+
SELECT * INTO v_session FROM public.plg_inventory_count_sessions
|
|
11019
|
+
WHERE id = p_session_id AND tenant_id = v_tenant FOR UPDATE;
|
|
11020
|
+
IF NOT FOUND THEN
|
|
11021
|
+
RETURN jsonb_build_object('ok', false, 'error', 'unknown count session');
|
|
11022
|
+
END IF;
|
|
11023
|
+
PERFORM app.inventory_authorize_location(v_tenant, v_session.stock_location_id, 'inventory.edit');
|
|
11024
|
+
|
|
11025
|
+
IF v_session.status = 'closed' THEN
|
|
11026
|
+
RETURN jsonb_build_object('ok', true, 'session_id', p_session_id,
|
|
11027
|
+
'status', 'closed', 'already_closed', true, 'adjustments_created', 0);
|
|
11028
|
+
END IF;
|
|
11029
|
+
IF v_session.status = 'cancelled' THEN
|
|
11030
|
+
RETURN jsonb_build_object('ok', false, 'error', 'this count was cancelled');
|
|
11031
|
+
END IF;
|
|
11032
|
+
|
|
11033
|
+
v_reason := COALESCE(NULLIF(btrim(COALESCE(p_reason, '')), ''), 'Stock count');
|
|
11034
|
+
|
|
11035
|
+
SELECT count(*) INTO v_uncounted
|
|
11036
|
+
FROM public.plg_inventory_count_items i
|
|
11037
|
+
WHERE i.session_id = p_session_id AND i.counted_quantity IS NULL;
|
|
11038
|
+
IF v_uncounted > 0 THEN
|
|
11039
|
+
RAISE EXCEPTION 'inventory: count session has % uncounted line(s)', v_uncounted
|
|
11040
|
+
USING ERRCODE = '22023', HINT = 'Count every line, including physical zero, before closing.';
|
|
11041
|
+
END IF;
|
|
11042
|
+
|
|
11043
|
+
PERFORM p.id
|
|
11044
|
+
FROM public.plg_inventory_stock_positions p
|
|
11045
|
+
JOIN public.plg_inventory_count_items i
|
|
11046
|
+
ON i.tenant_id = p.tenant_id AND i.product_id = p.product_id
|
|
11047
|
+
WHERE i.session_id = p_session_id
|
|
11048
|
+
AND p.stock_location_id = v_session.stock_location_id
|
|
11049
|
+
ORDER BY p.id
|
|
11050
|
+
FOR UPDATE OF p;
|
|
11051
|
+
|
|
11052
|
+
SELECT count(DISTINCT i.id) INTO v_conflicts
|
|
11053
|
+
FROM public.plg_inventory_count_items i
|
|
11054
|
+
JOIN public.plg_inventory_stock_movements m
|
|
11055
|
+
ON m.tenant_id = i.tenant_id
|
|
11056
|
+
AND m.product_id = i.product_id
|
|
11057
|
+
AND (m.source_location_id = v_session.stock_location_id
|
|
11058
|
+
OR m.destination_location_id = v_session.stock_location_id)
|
|
11059
|
+
WHERE i.session_id = p_session_id
|
|
11060
|
+
AND m.created_at > v_session.opened_at
|
|
11061
|
+
AND (i.movement_id IS NULL OR m.id <> i.movement_id);
|
|
11062
|
+
IF v_conflicts > 0 THEN
|
|
11063
|
+
RAISE EXCEPTION 'inventory: stock changed after this count opened (% line(s)); review and recount', v_conflicts
|
|
11064
|
+
USING ERRCODE = '40001';
|
|
11065
|
+
END IF;
|
|
11066
|
+
|
|
11067
|
+
SELECT * INTO v_op FROM app.inventory_begin_operation(
|
|
11068
|
+
v_tenant, 'adjust', v_key,
|
|
11069
|
+
jsonb_build_object('count_session_id', p_session_id, 'reason', v_reason));
|
|
11070
|
+
IF v_op.existing IS NOT NULL THEN
|
|
11071
|
+
UPDATE public.plg_inventory_count_sessions
|
|
11072
|
+
SET status = 'closed', closed_at = coalesce(closed_at, now()),
|
|
11073
|
+
closed_by = coalesce(closed_by, auth.uid()), updated_at = now()
|
|
11074
|
+
WHERE id = p_session_id;
|
|
11075
|
+
RETURN v_op.existing || jsonb_build_object('already_closed', true, 'adjustments_created', 0);
|
|
11076
|
+
END IF;
|
|
11077
|
+
|
|
11078
|
+
FOR v_item IN
|
|
11079
|
+
SELECT * FROM public.plg_inventory_count_items
|
|
11080
|
+
WHERE session_id = p_session_id AND movement_id IS NULL
|
|
11081
|
+
ORDER BY id
|
|
11082
|
+
FOR UPDATE
|
|
11083
|
+
LOOP
|
|
11084
|
+
SELECT coalesce(sum(p.quantity), 0),
|
|
11085
|
+
coalesce(max(p.unit_cost), v_item.unit_cost, 0),
|
|
11086
|
+
coalesce(bool_or((p.batch_number IS NOT NULL OR p.expiration_date IS NOT NULL) AND p.quantity <> 0), false)
|
|
11087
|
+
INTO v_current, v_current_cost, v_has_lots
|
|
11088
|
+
FROM public.plg_inventory_stock_positions p
|
|
11089
|
+
WHERE p.tenant_id = v_tenant
|
|
11090
|
+
AND p.product_id = v_item.product_id
|
|
11091
|
+
AND p.stock_location_id = v_session.stock_location_id;
|
|
11092
|
+
|
|
11093
|
+
v_delta := v_item.counted_quantity - v_current;
|
|
11094
|
+
IF v_delta = 0 THEN CONTINUE; END IF;
|
|
11095
|
+
IF v_has_lots THEN
|
|
11096
|
+
RAISE EXCEPTION 'inventory: product % has batch stock; close it with a batch-aware count', v_item.product_id
|
|
11097
|
+
USING ERRCODE = '22023';
|
|
11098
|
+
END IF;
|
|
11099
|
+
|
|
11100
|
+
v_line_no := v_line_no + 1;
|
|
11101
|
+
INSERT INTO public.plg_inventory_stock_movements (
|
|
11102
|
+
tenant_id, product_id, kind, quantity, unit_cost,
|
|
11103
|
+
source_location_id, document_type, reason,
|
|
11104
|
+
idempotency_key, line_no, operation_id,
|
|
11105
|
+
source_item_type, source_item_id, metadata
|
|
11106
|
+
) VALUES (
|
|
11107
|
+
v_tenant, v_item.product_id, 'adjust', v_delta, v_current_cost,
|
|
11108
|
+
v_session.stock_location_id, 'stock_count', v_reason,
|
|
11109
|
+
v_key, v_line_no, v_op.op_id,
|
|
11110
|
+
'stock_count_item', v_item.id,
|
|
11111
|
+
jsonb_build_object(
|
|
11112
|
+
'countSessionId', p_session_id,
|
|
11113
|
+
'countItemId', v_item.id,
|
|
11114
|
+
'systemQuantity', v_item.system_quantity,
|
|
11115
|
+
'currentQuantity', v_current,
|
|
11116
|
+
'countedQuantity', v_item.counted_quantity,
|
|
11117
|
+
'variance', v_delta
|
|
11118
|
+
)
|
|
11119
|
+
)
|
|
11120
|
+
RETURNING id INTO v_movement_id;
|
|
11121
|
+
|
|
11122
|
+
UPDATE public.plg_inventory_count_items
|
|
11123
|
+
SET movement_id = v_movement_id, updated_at = now()
|
|
11124
|
+
WHERE id = v_item.id;
|
|
11125
|
+
v_movements := v_movements || app.inventory_movement_json(v_movement_id);
|
|
11126
|
+
v_positions := v_positions || app.inventory_position_json(
|
|
11127
|
+
v_tenant, v_item.product_id, v_session.stock_location_id, NULL, NULL);
|
|
11128
|
+
v_emitted := v_emitted + 1;
|
|
11129
|
+
END LOOP;
|
|
11130
|
+
|
|
11131
|
+
UPDATE public.plg_inventory_count_sessions
|
|
11132
|
+
SET status = 'closed', closed_at = now(), closed_by = auth.uid(), updated_at = now()
|
|
11133
|
+
WHERE id = p_session_id;
|
|
11134
|
+
|
|
11135
|
+
v_result := jsonb_build_object(
|
|
11136
|
+
'ok', true, 'session_id', p_session_id, 'status', 'closed',
|
|
11137
|
+
'already_closed', false, 'adjustments_created', v_emitted,
|
|
11138
|
+
'operation_id', v_op.op_id, 'idempotency_key', v_key,
|
|
11139
|
+
'movements', v_movements, 'positions', v_positions);
|
|
11140
|
+
RETURN app.inventory_finish_operation(
|
|
11141
|
+
v_tenant, v_op.op_id, 'count.close', v_key, v_result,
|
|
11142
|
+
jsonb_build_object('count_session_id', p_session_id, 'adjustments_created', v_emitted));
|
|
11143
|
+
END;
|
|
11144
|
+
$$;
|
|
11145
|
+
`;
|
|
10587
11146
|
var MIGRATIONS = [
|
|
10588
11147
|
{ id: "000_baseline", sql: MIGRATION_000_BASELINE },
|
|
10589
11148
|
{ id: "001_the_usage_that_left_stock_can_be_audited", sql: MIGRATION_001_THE_USAGE_THAT_LEFT_STOCK_CAN_BE_AUDITED },
|
|
10590
|
-
{ id: "002_a_auditoria_fecha_em_lote", sql: MIGRATION_002_A_AUDITORIA_FECHA_EM_LOTE }
|
|
11149
|
+
{ id: "002_a_auditoria_fecha_em_lote", sql: MIGRATION_002_A_AUDITORIA_FECHA_EM_LOTE },
|
|
11150
|
+
{ id: "003_stock_count_closes_through_ledger", sql: MIGRATION_003_STOCK_COUNT_CLOSES_THROUGH_LEDGER }
|
|
10591
11151
|
];
|
|
10592
11152
|
|
|
10593
11153
|
// src/index.ts
|