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