@fayz-ai/plugin-inventory 0.12.2 → 0.12.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/index.js +311 -50
- package/dist/index.js.map +1 -1
- package/dist/lib/idempotency.d.ts +2 -0
- package/dist/lib/idempotency.d.ts.map +1 -0
- package/dist/lib/setup-guides.d.ts.map +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/migrations/index.d.ts +1 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/views/ProductStockTab.d.ts.map +1 -1
- package/dist/views/StockCountSessionView.d.ts.map +1 -1
- package/dist/views/StockMovementView.d.ts.map +1 -1
- package/package.json +4 -4
package/dist/data/mock.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mock.d.ts","sourceRoot":"","sources":["../../src/data/mock.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAA;AA8DpD,wBAAgB,2BAA2B,IAAI,qBAAqB,
|
|
1
|
+
{"version":3,"file":"mock.d.ts","sourceRoot":"","sources":["../../src/data/mock.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAA;AA8DpD,wBAAgB,2BAA2B,IAAI,qBAAqB,CA+sBnE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"supabase.d.ts","sourceRoot":"","sources":["../../src/data/supabase.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAA;
|
|
1
|
+
{"version":3,"file":"supabase.d.ts","sourceRoot":"","sources":["../../src/data/supabase.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAA;AA+YpD,wBAAgB,+BAA+B,IAAI,qBAAqB,CAiiCvE"}
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React3, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
|
2
2
|
import { toast, defineKpiWidget, defineCustomWidget, PageTransition, ModulePage, Button, SubpageHeader, SearchSelect, cn, DatePicker, ListView, ConfirmDialog, Badge, DraftCommitBar, Switch, useSaveBar, DataTable, DashboardCanvas, Modal, ModalContent, ModalHeader, ModalTitle, ModalDescription, ModalBody, ModalFooter, StockEntryForm, KpiCard, Card, CardHeader, CardTitle, CardContent, Skeleton } from '@fayz-ai/ui';
|
|
3
3
|
import { createPluginContext, PluginSettingsPanel, dedup, useModuleNavigation, createViewRouter, ModuleActionBar, useTenantPluginSettings, SettingsGroup, ToggleRow, usePermissionOptional, CrudListView, TeachMeLink, useLimitGuard, invalidateRelationOptions, CrudFormSkeleton, CrudFormPage, CrudDetailPage, formatCurrency, useAgentSurface, askAI, PermissionGate, QuickActionsButton, invalidateLimit } from '@fayz-ai/admin';
|
|
4
|
-
import { createSafeDataProvider, registerTranslations, countByTenant, useTranslation, getActiveUnitId, errorMessage, useDataChanged, getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core';
|
|
4
|
+
import { createSafeDataProvider, registerTranslations, countByTenant, useTranslation, getActiveUnitId, createPendingCommand, errorMessage, useDataChanged, getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core';
|
|
5
5
|
import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
|
|
6
6
|
import { Trash2, ArrowRightLeft, RefreshCw, ArrowUpRight, ArrowDownRight, Package, Ruler, MapPin, Loader2, Check, ChevronRight, CircleSlash, CircleCheck, ClipboardCheck, AlertTriangle, Plus, BookOpen, Layers, Clock, Coins, Building2, Undo2, PackageX } from 'lucide-react';
|
|
7
7
|
import { createStore as createStore$1 } from 'zustand/vanilla';
|
|
@@ -70,6 +70,17 @@ function normalizeMovementType(raw) {
|
|
|
70
70
|
if (MOVEMENT_TYPE_VALUES.includes(v)) return v;
|
|
71
71
|
return LEDGER_KIND_TO_TYPE[v] ?? LEGACY_PT_MOVEMENT_TYPE[v];
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
// src/lib/idempotency.ts
|
|
75
|
+
function createIdempotencyKey(scope) {
|
|
76
|
+
let suffix;
|
|
77
|
+
try {
|
|
78
|
+
suffix = typeof globalThis.crypto?.randomUUID === "function" ? globalThis.crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
79
|
+
} catch {
|
|
80
|
+
suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
81
|
+
}
|
|
82
|
+
return `${scope}:${suffix}`;
|
|
83
|
+
}
|
|
73
84
|
function TableSkeleton() {
|
|
74
85
|
return /* @__PURE__ */ jsx("div", { className: "rounded-card border p-4 space-y-2", children: [1, 2, 3].map((i) => /* @__PURE__ */ jsx("div", { className: "h-3.5 rounded bg-muted/30 animate-pulse" }, i)) });
|
|
75
86
|
}
|
|
@@ -87,6 +98,8 @@ function ProductStockTab({ item, onChanged }) {
|
|
|
87
98
|
const [undoing, setUndoing] = useState(false);
|
|
88
99
|
const [entryVerb, setEntryVerb] = useState(null);
|
|
89
100
|
const [saving, setSaving] = useState(false);
|
|
101
|
+
const command = useRef(createPendingCommand(() => createIdempotencyKey("inventory:product-movement"), (input) => input.productId));
|
|
102
|
+
const [pending, setPending] = useState(false);
|
|
90
103
|
const load = useCallback(async () => {
|
|
91
104
|
setLoading(true);
|
|
92
105
|
try {
|
|
@@ -104,25 +117,32 @@ function ProductStockTab({ item, onChanged }) {
|
|
|
104
117
|
void load();
|
|
105
118
|
}, [load]);
|
|
106
119
|
const handleEntry = useCallback(async (entry) => {
|
|
120
|
+
if (command.current.running) return;
|
|
107
121
|
setSaving(true);
|
|
122
|
+
setPending(true);
|
|
108
123
|
try {
|
|
109
|
-
await
|
|
124
|
+
await command.current.run({
|
|
110
125
|
productId: item.id,
|
|
111
126
|
movementType: entry.verb,
|
|
112
127
|
quantity: entry.quantity,
|
|
113
128
|
...entry.reasonCode ? { reason: entry.reasonCode } : {},
|
|
114
129
|
...entry.note ? { notes: entry.note } : {},
|
|
115
130
|
...entry.occurredOn ? { movementDate: entry.occurredOn } : {}
|
|
116
|
-
});
|
|
131
|
+
}, (input) => provider.createMovement(input));
|
|
117
132
|
setEntryVerb(null);
|
|
118
|
-
|
|
119
|
-
|
|
133
|
+
try {
|
|
134
|
+
await load();
|
|
135
|
+
onChanged?.();
|
|
136
|
+
} catch {
|
|
137
|
+
toast.warning(t2("inventory.stock.refreshAfterCommit"));
|
|
138
|
+
}
|
|
120
139
|
} catch (error) {
|
|
121
140
|
toast.error(t2("inventory.productDetail.movementFailed"), {
|
|
122
141
|
description: error.message
|
|
123
142
|
});
|
|
124
143
|
} finally {
|
|
125
144
|
setSaving(false);
|
|
145
|
+
setPending(command.current.pending);
|
|
126
146
|
}
|
|
127
147
|
}, [item.id, provider, load, onChanged, t2]);
|
|
128
148
|
const ordered = useMemo(
|
|
@@ -260,8 +280,12 @@ function ProductStockTab({ item, onChanged }) {
|
|
|
260
280
|
StockEntryForm,
|
|
261
281
|
{
|
|
262
282
|
verb: entryVerb,
|
|
263
|
-
onVerbChange:
|
|
264
|
-
|
|
283
|
+
onVerbChange: (verb) => {
|
|
284
|
+
if (!command.current.pending) setEntryVerb(verb);
|
|
285
|
+
},
|
|
286
|
+
onClose: () => {
|
|
287
|
+
if (!command.current.pending) setEntryVerb(null);
|
|
288
|
+
},
|
|
265
289
|
verbs: MOVEMENT_TYPES.map((m) => ({
|
|
266
290
|
value: m.value,
|
|
267
291
|
label: t2(m.labelKey),
|
|
@@ -269,6 +293,8 @@ function ProductStockTab({ item, onChanged }) {
|
|
|
269
293
|
})),
|
|
270
294
|
available: onHand,
|
|
271
295
|
saving,
|
|
296
|
+
readOnly: pending,
|
|
297
|
+
notice: pending ? t2("inventory.stock.pendingRetry") : void 0,
|
|
272
298
|
onSubmit: (entry) => void handleEntry(entry),
|
|
273
299
|
labels: {
|
|
274
300
|
title: t2("inventory.productDetail.newMovement"),
|
|
@@ -985,6 +1011,8 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
985
1011
|
const [supplierLabel, setSupplierLabel] = useState("");
|
|
986
1012
|
const [saving, setSaving] = useState(false);
|
|
987
1013
|
const [savedMovement, setSavedMovement] = useState(null);
|
|
1014
|
+
const command = useRef(createPendingCommand(() => createIdempotencyKey("inventory:manual-movement"), (input) => input.productId));
|
|
1015
|
+
const [pending, setPending] = useState(false);
|
|
988
1016
|
useEffect(() => {
|
|
989
1017
|
fetchProducts({});
|
|
990
1018
|
fetchLocations();
|
|
@@ -1052,10 +1080,11 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1052
1080
|
const canProceedStep2 = quantity > 0 && (!needsReason || reason.trim()) && (!needsDest || destLocationId);
|
|
1053
1081
|
const title = defaultType === "entry" ? t2("inventory.stock.entry") : defaultType === "exit" ? t2("inventory.stock.exit") : t2("inventory.stock.movement");
|
|
1054
1082
|
async function handleSave() {
|
|
1055
|
-
if (!productId || quantity <= 0) return;
|
|
1083
|
+
if (command.current.running || !command.current.pending && (!productId || quantity <= 0)) return;
|
|
1056
1084
|
setSaving(true);
|
|
1085
|
+
setPending(true);
|
|
1057
1086
|
try {
|
|
1058
|
-
const movement = await
|
|
1087
|
+
const movement = await command.current.run({
|
|
1059
1088
|
productId,
|
|
1060
1089
|
quantity,
|
|
1061
1090
|
movementType,
|
|
@@ -1068,10 +1097,12 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1068
1097
|
batchNumber: batchNumber || void 0,
|
|
1069
1098
|
expirationDate: expirationDate || void 0,
|
|
1070
1099
|
supplierId: movementType === "entry" ? supplierId || void 0 : void 0
|
|
1071
|
-
});
|
|
1100
|
+
}, createMovement);
|
|
1072
1101
|
setSavedMovement(movement);
|
|
1102
|
+
} catch {
|
|
1073
1103
|
} finally {
|
|
1074
1104
|
setSaving(false);
|
|
1105
|
+
setPending(command.current.pending);
|
|
1075
1106
|
}
|
|
1076
1107
|
}
|
|
1077
1108
|
return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
|
|
@@ -1080,7 +1111,12 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1080
1111
|
{
|
|
1081
1112
|
title,
|
|
1082
1113
|
subtitle: t2("inventory.stock.stepOf", { step: String(step) }),
|
|
1083
|
-
onBack:
|
|
1114
|
+
onBack: () => {
|
|
1115
|
+
if (!command.current.pending && !command.current.running) {
|
|
1116
|
+
if (step > 1) setStep(step - 1);
|
|
1117
|
+
else onSaved?.();
|
|
1118
|
+
}
|
|
1119
|
+
},
|
|
1084
1120
|
parentLabel: t2("inventory.nav.stock")
|
|
1085
1121
|
}
|
|
1086
1122
|
),
|
|
@@ -1109,6 +1145,7 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1109
1145
|
/* @__PURE__ */ jsx(
|
|
1110
1146
|
SearchSelect,
|
|
1111
1147
|
{
|
|
1148
|
+
"data-tour": "inventory.entry-product",
|
|
1112
1149
|
value: productId,
|
|
1113
1150
|
displayValue: productLabel,
|
|
1114
1151
|
onChange: handleProductSelect,
|
|
@@ -1290,7 +1327,8 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1290
1327
|
] })
|
|
1291
1328
|
] })
|
|
1292
1329
|
] }),
|
|
1293
|
-
/* @__PURE__ */
|
|
1330
|
+
pending && /* @__PURE__ */ jsx("p", { role: "status", className: "text-sm text-warning", children: t2("inventory.stock.pendingRetry") }),
|
|
1331
|
+
/* @__PURE__ */ jsxs("fieldset", { disabled: pending, className: "rounded-card border bg-card shadow-sm p-5 space-y-4", children: [
|
|
1294
1332
|
/* @__PURE__ */ jsx("p", { className: "text-xs font-medium text-muted-foreground", children: t2("inventory.stock.additionalDetails") }),
|
|
1295
1333
|
/* @__PURE__ */ jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [
|
|
1296
1334
|
movementType === "entry" && /* @__PURE__ */ jsx(
|
|
@@ -1331,10 +1369,13 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1331
1369
|
] })
|
|
1332
1370
|
] }),
|
|
1333
1371
|
/* @__PURE__ */ jsxs("div", { className: "flex justify-between", children: [
|
|
1334
|
-
/* @__PURE__ */ jsx("button", {
|
|
1372
|
+
/* @__PURE__ */ jsx("button", { disabled: pending, onClick: () => {
|
|
1373
|
+
if (!command.current.pending) setStep(2);
|
|
1374
|
+
}, className: "rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted bg-card shadow-button active:shadow-button-inset transition-colors", children: t2("inventory.stock.back") }),
|
|
1335
1375
|
/* @__PURE__ */ jsxs(
|
|
1336
1376
|
"button",
|
|
1337
1377
|
{
|
|
1378
|
+
"data-tour": "inventory.entry-save",
|
|
1338
1379
|
onClick: handleSave,
|
|
1339
1380
|
disabled: saving,
|
|
1340
1381
|
className: "inline-flex items-center gap-1.5 rounded-button bg-primary border border-primary px-5 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 shadow-button-primary active:shadow-button-inset transition-colors disabled:opacity-50",
|
|
@@ -1796,6 +1837,15 @@ function StockCountSessionView({ sessionId, onBack }) {
|
|
|
1796
1837
|
setConfirmingClose(false);
|
|
1797
1838
|
}
|
|
1798
1839
|
}
|
|
1840
|
+
function requestClose() {
|
|
1841
|
+
if (summary.uncounted > 0) {
|
|
1842
|
+
toast.error(t2("inventory.counts.incomplete"), {
|
|
1843
|
+
description: t2("inventory.counts.incompleteDescription", { count: String(summary.uncounted) })
|
|
1844
|
+
});
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
setConfirmingClose(true);
|
|
1848
|
+
}
|
|
1799
1849
|
async function handleCancel() {
|
|
1800
1850
|
try {
|
|
1801
1851
|
await provider.cancelCountSession(sessionId);
|
|
@@ -1861,7 +1911,7 @@ function StockCountSessionView({ sessionId, onBack }) {
|
|
|
1861
1911
|
] }),
|
|
1862
1912
|
!closed && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
1863
1913
|
/* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: () => setConfirmingCancel(true), children: t2("inventory.counts.cancelCount") }),
|
|
1864
|
-
/* @__PURE__ */ jsx(Button, { size: "sm", onClick:
|
|
1914
|
+
/* @__PURE__ */ jsx(Button, { size: "sm", onClick: requestClose, children: t2("inventory.counts.close") })
|
|
1865
1915
|
] })
|
|
1866
1916
|
] }),
|
|
1867
1917
|
/* @__PURE__ */ jsxs("div", { className: "rounded-card border bg-card shadow-sm divide-y", children: [
|
|
@@ -1907,10 +1957,7 @@ function StockCountSessionView({ sessionId, onBack }) {
|
|
|
1907
1957
|
{
|
|
1908
1958
|
open: confirmingClose,
|
|
1909
1959
|
title: t2("inventory.counts.closeTitle"),
|
|
1910
|
-
description: t2("inventory.counts.closeDescription", {
|
|
1911
|
-
divergent: String(summary.divergent),
|
|
1912
|
-
uncounted: String(summary.uncounted)
|
|
1913
|
-
}),
|
|
1960
|
+
description: t2("inventory.counts.closeDescription", { divergent: String(summary.divergent) }),
|
|
1914
1961
|
confirmLabel: t2("inventory.counts.closeConfirm"),
|
|
1915
1962
|
cancelLabel: t2("inventory.counts.cancel"),
|
|
1916
1963
|
loading: closing,
|
|
@@ -3669,6 +3716,7 @@ function createStore() {
|
|
|
3669
3716
|
function createMockInventoryProvider() {
|
|
3670
3717
|
const store = createStore();
|
|
3671
3718
|
const tenantId = "mock-tenant";
|
|
3719
|
+
const countMovementWatermarks = /* @__PURE__ */ new Map();
|
|
3672
3720
|
function withProduct(recipe) {
|
|
3673
3721
|
const product = store.products.find((p) => p.id === recipe.productId);
|
|
3674
3722
|
return { ...recipe, productName: product?.name ?? recipe.productName, productSalePrice: product?.salePrice };
|
|
@@ -3956,6 +4004,7 @@ function createMockInventoryProvider() {
|
|
|
3956
4004
|
updatedAt: now()
|
|
3957
4005
|
};
|
|
3958
4006
|
store.countSessions.push(session);
|
|
4007
|
+
countMovementWatermarks.set(session.id, new Set(store.movements.map((movement) => movement.id)));
|
|
3959
4008
|
for (const product of store.products) {
|
|
3960
4009
|
if (!product.isActive || product.productType === "asset") continue;
|
|
3961
4010
|
if (input.categoryId && product.categoryId !== input.categoryId) continue;
|
|
@@ -3995,20 +4044,37 @@ function createMockInventoryProvider() {
|
|
|
3995
4044
|
return { sessionId, adjustmentsCreated: 0, alreadyClosed: true };
|
|
3996
4045
|
}
|
|
3997
4046
|
if (session.status === "cancelled") throw new Error("This count was cancelled");
|
|
4047
|
+
const sessionItems = store.countItems.filter((item) => item.sessionId === sessionId);
|
|
4048
|
+
const uncounted = sessionItems.filter((item) => item.countedQuantity === void 0);
|
|
4049
|
+
if (uncounted.length > 0) {
|
|
4050
|
+
throw new Error(`Count every line before closing (${uncounted.length} uncounted)`);
|
|
4051
|
+
}
|
|
4052
|
+
const movementWatermark = countMovementWatermarks.get(sessionId) ?? /* @__PURE__ */ new Set();
|
|
4053
|
+
const changedAfterOpening = store.movements.some(
|
|
4054
|
+
(movement) => !movementWatermark.has(movement.id) && sessionItems.some((item) => item.productId === movement.productId) && (movement.stockLocationId === session.stockLocationId || movement.destinationLocationId === session.stockLocationId)
|
|
4055
|
+
);
|
|
4056
|
+
if (changedAfterOpening) {
|
|
4057
|
+
throw new Error("Stock changed after this count opened; review and recount");
|
|
4058
|
+
}
|
|
3998
4059
|
let emitted = 0;
|
|
3999
|
-
for (const item of
|
|
4000
|
-
if (item.sessionId !== sessionId) continue;
|
|
4060
|
+
for (const item of sessionItems) {
|
|
4001
4061
|
if (!isDivergent(item) || item.movementId) continue;
|
|
4002
4062
|
const counted = item.countedQuantity;
|
|
4003
4063
|
const delta = counted - item.systemQuantity;
|
|
4064
|
+
const hasBatchStock = store.positions.some(
|
|
4065
|
+
(position) => position.productId === item.productId && position.stockLocationId === session.stockLocationId && position.quantity !== 0 && Boolean(position.batchNumber || position.expirationDate)
|
|
4066
|
+
);
|
|
4067
|
+
if (hasBatchStock) {
|
|
4068
|
+
throw new Error(`Product ${item.productId} has batch stock; use a batch-aware count`);
|
|
4069
|
+
}
|
|
4004
4070
|
const movement = {
|
|
4005
4071
|
id: uid(),
|
|
4006
4072
|
productId: item.productId,
|
|
4007
4073
|
productName: item.productName,
|
|
4008
|
-
quantity:
|
|
4074
|
+
quantity: delta,
|
|
4009
4075
|
movementType: "adjustment",
|
|
4010
4076
|
unitCost: item.unitCost,
|
|
4011
|
-
totalCost: item.unitCost *
|
|
4077
|
+
totalCost: item.unitCost * Math.abs(delta),
|
|
4012
4078
|
stockLocationId: session.stockLocationId,
|
|
4013
4079
|
stockLocationName: session.stockLocationName,
|
|
4014
4080
|
reason: reason || "Stock count",
|
|
@@ -4037,6 +4103,7 @@ function createMockInventoryProvider() {
|
|
|
4037
4103
|
session.status = "closed";
|
|
4038
4104
|
session.closedAt = now();
|
|
4039
4105
|
session.updatedAt = now();
|
|
4106
|
+
countMovementWatermarks.delete(sessionId);
|
|
4040
4107
|
return { sessionId, adjustmentsCreated: emitted, alreadyClosed: false };
|
|
4041
4108
|
},
|
|
4042
4109
|
async cancelCountSession(sessionId) {
|
|
@@ -4045,6 +4112,7 @@ function createMockInventoryProvider() {
|
|
|
4045
4112
|
if (session.status === "closed") throw new Error("A closed count cannot be cancelled");
|
|
4046
4113
|
session.status = "cancelled";
|
|
4047
4114
|
session.updatedAt = now();
|
|
4115
|
+
countMovementWatermarks.delete(sessionId);
|
|
4048
4116
|
return session;
|
|
4049
4117
|
},
|
|
4050
4118
|
// --- Recipes ---
|
|
@@ -4788,6 +4856,7 @@ function createSupabaseInventoryProvider() {
|
|
|
4788
4856
|
if (!locationId) {
|
|
4789
4857
|
throw new Error("inventory: this tenant has no stock location to record the movement against");
|
|
4790
4858
|
}
|
|
4859
|
+
const idempotencyKey = input.idempotencyKey ?? createIdempotencyKey(`inventory:${kind}`);
|
|
4791
4860
|
const { data: opResult, error } = kind === "in" ? await pub.rpc("inventory_receive", {
|
|
4792
4861
|
p_location: locationId,
|
|
4793
4862
|
p_lines: [line],
|
|
@@ -4796,32 +4865,37 @@ function createSupabaseInventoryProvider() {
|
|
|
4796
4865
|
...input.documentNumber ? { ref: input.documentNumber } : {},
|
|
4797
4866
|
...input.supplierId ? { supplier_id: input.supplierId } : {},
|
|
4798
4867
|
...input.movementDate ? { date: input.movementDate } : {}
|
|
4799
|
-
}
|
|
4868
|
+
},
|
|
4869
|
+
p_idempotency_key: idempotencyKey
|
|
4800
4870
|
}) : await pub.rpc("inventory_adjust", {
|
|
4801
4871
|
p_location: locationId,
|
|
4802
4872
|
p_lines: [line],
|
|
4803
4873
|
// The ledger refuses an adjustment with no reason, and the verb is one
|
|
4804
4874
|
// when the user did not type another.
|
|
4805
|
-
p_reason: input.reason ?? input.movementType
|
|
4875
|
+
p_reason: input.reason ?? input.movementType,
|
|
4876
|
+
p_idempotency_key: idempotencyKey
|
|
4806
4877
|
});
|
|
4807
|
-
if (error) throw
|
|
4878
|
+
if (error) throw error;
|
|
4808
4879
|
const written = opResult?.movements?.[0];
|
|
4809
4880
|
if (!written) throw new Error("inventory: the ledger accepted the operation but returned no movement");
|
|
4810
4881
|
const data = { ...written, tenant_id: tenantId, movement_type: input.movementType };
|
|
4811
|
-
const { data: product } = await core.from(PRODUCTS_READ).select("id, name, stock").eq("id", input.productId).single();
|
|
4812
4882
|
const movement = snakeToCamel(data);
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4883
|
+
try {
|
|
4884
|
+
const { data: product } = await core.from(PRODUCTS_READ).select("id, name, stock").eq("id", input.productId).single();
|
|
4885
|
+
movement.productName = product?.name;
|
|
4886
|
+
if (input.stockLocationId) {
|
|
4887
|
+
const { data: loc } = await pub.from(T.stockLocations).select("name").eq("id", input.stockLocationId).single();
|
|
4888
|
+
movement.stockLocationName = loc?.name;
|
|
4889
|
+
}
|
|
4890
|
+
if (input.destinationLocationId) {
|
|
4891
|
+
const { data: loc } = await pub.from(T.stockLocations).select("name").eq("id", input.destinationLocationId).single();
|
|
4892
|
+
movement.destinationLocationName = loc?.name;
|
|
4893
|
+
}
|
|
4894
|
+
if (input.supplierId) {
|
|
4895
|
+
const { data: supplier } = await core.from("people").select("name").eq("id", input.supplierId).single();
|
|
4896
|
+
movement.supplierName = supplier?.name;
|
|
4897
|
+
}
|
|
4898
|
+
} catch {
|
|
4825
4899
|
}
|
|
4826
4900
|
return movement;
|
|
4827
4901
|
},
|
|
@@ -5459,16 +5533,20 @@ function createInventoryStore(provider) {
|
|
|
5459
5533
|
}
|
|
5460
5534
|
},
|
|
5461
5535
|
async createMovement(input) {
|
|
5536
|
+
let movement;
|
|
5462
5537
|
try {
|
|
5463
|
-
|
|
5464
|
-
const [summary] = await Promise.all([provider.getSummary()]);
|
|
5465
|
-
set({ summary });
|
|
5466
|
-
toast.success("Stock movement recorded");
|
|
5467
|
-
return movement;
|
|
5538
|
+
movement = await provider.createMovement(input);
|
|
5468
5539
|
} catch (err) {
|
|
5469
5540
|
toast.error("Failed to record movement", { description: err?.message });
|
|
5470
5541
|
throw err;
|
|
5471
5542
|
}
|
|
5543
|
+
try {
|
|
5544
|
+
set({ summary: await provider.getSummary() });
|
|
5545
|
+
toast.success("Stock movement recorded");
|
|
5546
|
+
} catch {
|
|
5547
|
+
toast.warning("Stock movement recorded; refresh the list to see the updated balance");
|
|
5548
|
+
}
|
|
5549
|
+
return movement;
|
|
5472
5550
|
},
|
|
5473
5551
|
async createRecipe(input) {
|
|
5474
5552
|
try {
|
|
@@ -5647,8 +5725,12 @@ var products = {
|
|
|
5647
5725
|
check: "existe pelo menos um local \u2014 \xE9 onde o saldo vai morar"
|
|
5648
5726
|
}
|
|
5649
5727
|
],
|
|
5728
|
+
// O tour navega até o cadastro de produto; os passos sem âncora ficam de
|
|
5729
|
+
// briefing (a tela é um CRUD genérico, sem campos ancoráveis por enquanto).
|
|
5650
5730
|
steps: [
|
|
5651
|
-
{ do: "Estoque
|
|
5731
|
+
{ do: "O Estoque mora aqui.", anchor: "nav./inventory", route: "/inventory" },
|
|
5732
|
+
{ do: "Produtos ficam neste menu.", anchor: "modnav.products", route: "/inventory/products/list" },
|
|
5733
|
+
{ do: "Cadastre um produto novo aqui.", anchor: "action.new-product", route: "/inventory/products/list" },
|
|
5652
5734
|
{
|
|
5653
5735
|
do: "Escolha a unidade em que voc\xEA CONTA o produto, n\xE3o a que o fornecedor vende.",
|
|
5654
5736
|
why: "Se voc\xEA conta ampolas e ele vende caixa, o produto \xE9 em ampola \u2014 a convers\xE3o \xE9 problema da entrada."
|
|
@@ -5686,10 +5768,16 @@ var movements = {
|
|
|
5686
5768
|
check: "o movimento pede um local e existe op\xE7\xE3o"
|
|
5687
5769
|
}
|
|
5688
5770
|
],
|
|
5771
|
+
// Navega até a tela de entrada; a `practice` deixa a pessoa dar a entrada.
|
|
5689
5772
|
steps: [
|
|
5690
|
-
{ do: "Estoque
|
|
5691
|
-
{ do: "
|
|
5692
|
-
{ do: "
|
|
5773
|
+
{ do: "O Estoque mora aqui.", anchor: "nav./inventory", route: "/inventory" },
|
|
5774
|
+
{ do: "Abra Entrada neste menu para lan\xE7ar a compra que chegou.", anchor: "modnav.stock-entry", route: "/inventory/stock/entry" },
|
|
5775
|
+
{ do: "Sa\xEDda \xE9 para o que foi descartado, perdido ou usado fora de um atendimento." },
|
|
5776
|
+
{ do: "O hist\xF3rico mostra tudo, inclusive o que o sistema lan\xE7ou sozinho." }
|
|
5777
|
+
],
|
|
5778
|
+
practice: [
|
|
5779
|
+
{ anchor: "inventory.entry-product", label: "Escolha o produto que chegou" },
|
|
5780
|
+
{ anchor: "inventory.entry-save", label: "Confirme a entrada", final: true }
|
|
5693
5781
|
],
|
|
5694
5782
|
rules: [
|
|
5695
5783
|
"O raz\xE3o \xE9 append-only: nada \xE9 apagado. Corrigir \xE9 lan\xE7ar o movimento contr\xE1rio \u2014 e o hist\xF3rico guarda os dois.",
|
|
@@ -5867,6 +5955,8 @@ function buildInventoryOnboarding() {
|
|
|
5867
5955
|
|
|
5868
5956
|
// src/locales/en.ts
|
|
5869
5957
|
var en = {
|
|
5958
|
+
"inventory.stock.pendingRetry": "Confirmation is pending. Do not create another entry. Retry the same details before leaving this screen.",
|
|
5959
|
+
"inventory.stock.refreshAfterCommit": "Movement recorded. Refresh the screen to see the balance.",
|
|
5870
5960
|
"inventory.dashboard.activeItems": "Active items",
|
|
5871
5961
|
"inventory.dashboard.belowMinimum": "Below minimum",
|
|
5872
5962
|
"inventory.dashboard.entries": "Entries:",
|
|
@@ -6255,7 +6345,9 @@ var en = {
|
|
|
6255
6345
|
"inventory.counts.saveFailed": "Could not save this line",
|
|
6256
6346
|
"inventory.counts.close": "Close the count",
|
|
6257
6347
|
"inventory.counts.closeTitle": "Close the count?",
|
|
6258
|
-
"inventory.counts.closeDescription": "{{divergent}} line(s) differ from the system and will get an adjustment.
|
|
6348
|
+
"inventory.counts.closeDescription": "{{divergent}} line(s) differ from the system and will get an adjustment.",
|
|
6349
|
+
"inventory.counts.incomplete": "Finish the count before closing",
|
|
6350
|
+
"inventory.counts.incompleteDescription": "{{count}} line(s) are still blank. Enter zero when the item is physically out of stock.",
|
|
6259
6351
|
"inventory.counts.closeConfirm": "Close and adjust",
|
|
6260
6352
|
"inventory.counts.closed": "Count closed \u2014 {{count}} adjustment(s) recorded",
|
|
6261
6353
|
"inventory.counts.alreadyClosed": "This count was already closed",
|
|
@@ -6316,6 +6408,8 @@ var en = {
|
|
|
6316
6408
|
|
|
6317
6409
|
// src/locales/pt-BR.ts
|
|
6318
6410
|
var ptBR = {
|
|
6411
|
+
"inventory.stock.pendingRetry": "A confirma\xE7\xE3o ainda est\xE1 pendente. N\xE3o fa\xE7a outro lan\xE7amento. Tente novamente com os mesmos dados antes de sair desta tela.",
|
|
6412
|
+
"inventory.stock.refreshAfterCommit": "Movimento registrado. Atualize a tela para consultar o saldo.",
|
|
6319
6413
|
"inventory.dashboard.activeItems": "Itens ativos",
|
|
6320
6414
|
"inventory.dashboard.belowMinimum": "Abaixo do m\xEDnimo",
|
|
6321
6415
|
"inventory.dashboard.entries": "Entradas:",
|
|
@@ -6704,7 +6798,9 @@ var ptBR = {
|
|
|
6704
6798
|
"inventory.counts.saveFailed": "N\xE3o foi poss\xEDvel salvar esta linha",
|
|
6705
6799
|
"inventory.counts.close": "Fechar a contagem",
|
|
6706
6800
|
"inventory.counts.closeTitle": "Fechar a contagem?",
|
|
6707
|
-
"inventory.counts.closeDescription": "{{divergent}} linha(s) divergem do sistema e v\xE3o receber um ajuste.
|
|
6801
|
+
"inventory.counts.closeDescription": "{{divergent}} linha(s) divergem do sistema e v\xE3o receber um ajuste.",
|
|
6802
|
+
"inventory.counts.incomplete": "Finalize a contagem antes de fechar",
|
|
6803
|
+
"inventory.counts.incompleteDescription": "Ainda existem {{count}} linha(s) em branco. Informe zero quando o item acabou fisicamente.",
|
|
6708
6804
|
"inventory.counts.closeConfirm": "Fechar e ajustar",
|
|
6709
6805
|
"inventory.counts.closed": "Contagem fechada \u2014 {{count}} ajuste(s) lan\xE7ado(s)",
|
|
6710
6806
|
"inventory.counts.alreadyClosed": "Esta contagem j\xE1 estava fechada",
|
|
@@ -10883,10 +10979,175 @@ REVOKE ALL ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) FROM PU
|
|
|
10883
10979
|
GRANT ALL ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) TO authenticated;
|
|
10884
10980
|
GRANT ALL ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) TO service_role;
|
|
10885
10981
|
`;
|
|
10982
|
+
var MIGRATION_003_STOCK_COUNT_CLOSES_THROUGH_LEDGER = `-- 003_stock_count_closes_through_ledger.sql
|
|
10983
|
+
--
|
|
10984
|
+
-- A physical count is a reconciliation, not a second stock writer. The old
|
|
10985
|
+
-- function inserted values into GENERATED columns, recorded the counted total
|
|
10986
|
+
-- instead of the variance and then updated the position a second time outside
|
|
10987
|
+
-- the ledger trigger. This replacement emits one canonical \`adjust\` movement
|
|
10988
|
+
-- per divergent line and lets the existing movement trigger update the balance.
|
|
10989
|
+
--
|
|
10990
|
+
-- Safety gates: every line must be counted (zero is valid); any stock movement
|
|
10991
|
+
-- after the opening snapshot aborts the close; lot stock requires a future
|
|
10992
|
+
-- batch-aware count instead of guessing which lot changed.
|
|
10993
|
+
|
|
10994
|
+
CREATE OR REPLACE FUNCTION public.inventory_close_count_session(p_session_id uuid, p_reason text DEFAULT NULL::text) RETURNS jsonb
|
|
10995
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
10996
|
+
SET search_path TO ''
|
|
10997
|
+
AS $$
|
|
10998
|
+
DECLARE
|
|
10999
|
+
v_tenant uuid := app.inventory_require_tenant();
|
|
11000
|
+
v_session public.plg_inventory_count_sessions%ROWTYPE;
|
|
11001
|
+
v_item public.plg_inventory_count_items%ROWTYPE;
|
|
11002
|
+
v_op record;
|
|
11003
|
+
v_delta numeric;
|
|
11004
|
+
v_current numeric;
|
|
11005
|
+
v_current_cost numeric;
|
|
11006
|
+
v_has_lots boolean;
|
|
11007
|
+
v_movement_id uuid;
|
|
11008
|
+
v_reason text;
|
|
11009
|
+
v_key text := 'count:' || p_session_id::text || ':close:v1';
|
|
11010
|
+
v_line_no int := 0;
|
|
11011
|
+
v_uncounted int;
|
|
11012
|
+
v_conflicts int;
|
|
11013
|
+
v_emitted int := 0;
|
|
11014
|
+
v_movements jsonb := '[]'::jsonb;
|
|
11015
|
+
v_positions jsonb := '[]'::jsonb;
|
|
11016
|
+
v_result jsonb;
|
|
11017
|
+
BEGIN
|
|
11018
|
+
SELECT * INTO v_session FROM public.plg_inventory_count_sessions
|
|
11019
|
+
WHERE id = p_session_id AND tenant_id = v_tenant FOR UPDATE;
|
|
11020
|
+
IF NOT FOUND THEN
|
|
11021
|
+
RETURN jsonb_build_object('ok', false, 'error', 'unknown count session');
|
|
11022
|
+
END IF;
|
|
11023
|
+
PERFORM app.inventory_authorize_location(v_tenant, v_session.stock_location_id, 'inventory.edit');
|
|
11024
|
+
|
|
11025
|
+
IF v_session.status = 'closed' THEN
|
|
11026
|
+
RETURN jsonb_build_object('ok', true, 'session_id', p_session_id,
|
|
11027
|
+
'status', 'closed', 'already_closed', true, 'adjustments_created', 0);
|
|
11028
|
+
END IF;
|
|
11029
|
+
IF v_session.status = 'cancelled' THEN
|
|
11030
|
+
RETURN jsonb_build_object('ok', false, 'error', 'this count was cancelled');
|
|
11031
|
+
END IF;
|
|
11032
|
+
|
|
11033
|
+
v_reason := COALESCE(NULLIF(btrim(COALESCE(p_reason, '')), ''), 'Stock count');
|
|
11034
|
+
|
|
11035
|
+
SELECT count(*) INTO v_uncounted
|
|
11036
|
+
FROM public.plg_inventory_count_items i
|
|
11037
|
+
WHERE i.session_id = p_session_id AND i.counted_quantity IS NULL;
|
|
11038
|
+
IF v_uncounted > 0 THEN
|
|
11039
|
+
RAISE EXCEPTION 'inventory: count session has % uncounted line(s)', v_uncounted
|
|
11040
|
+
USING ERRCODE = '22023', HINT = 'Count every line, including physical zero, before closing.';
|
|
11041
|
+
END IF;
|
|
11042
|
+
|
|
11043
|
+
PERFORM p.id
|
|
11044
|
+
FROM public.plg_inventory_stock_positions p
|
|
11045
|
+
JOIN public.plg_inventory_count_items i
|
|
11046
|
+
ON i.tenant_id = p.tenant_id AND i.product_id = p.product_id
|
|
11047
|
+
WHERE i.session_id = p_session_id
|
|
11048
|
+
AND p.stock_location_id = v_session.stock_location_id
|
|
11049
|
+
ORDER BY p.id
|
|
11050
|
+
FOR UPDATE OF p;
|
|
11051
|
+
|
|
11052
|
+
SELECT count(DISTINCT i.id) INTO v_conflicts
|
|
11053
|
+
FROM public.plg_inventory_count_items i
|
|
11054
|
+
JOIN public.plg_inventory_stock_movements m
|
|
11055
|
+
ON m.tenant_id = i.tenant_id
|
|
11056
|
+
AND m.product_id = i.product_id
|
|
11057
|
+
AND (m.source_location_id = v_session.stock_location_id
|
|
11058
|
+
OR m.destination_location_id = v_session.stock_location_id)
|
|
11059
|
+
WHERE i.session_id = p_session_id
|
|
11060
|
+
AND m.created_at > v_session.opened_at
|
|
11061
|
+
AND (i.movement_id IS NULL OR m.id <> i.movement_id);
|
|
11062
|
+
IF v_conflicts > 0 THEN
|
|
11063
|
+
RAISE EXCEPTION 'inventory: stock changed after this count opened (% line(s)); review and recount', v_conflicts
|
|
11064
|
+
USING ERRCODE = '40001';
|
|
11065
|
+
END IF;
|
|
11066
|
+
|
|
11067
|
+
SELECT * INTO v_op FROM app.inventory_begin_operation(
|
|
11068
|
+
v_tenant, 'adjust', v_key,
|
|
11069
|
+
jsonb_build_object('count_session_id', p_session_id, 'reason', v_reason));
|
|
11070
|
+
IF v_op.existing IS NOT NULL THEN
|
|
11071
|
+
UPDATE public.plg_inventory_count_sessions
|
|
11072
|
+
SET status = 'closed', closed_at = coalesce(closed_at, now()),
|
|
11073
|
+
closed_by = coalesce(closed_by, auth.uid()), updated_at = now()
|
|
11074
|
+
WHERE id = p_session_id;
|
|
11075
|
+
RETURN v_op.existing || jsonb_build_object('already_closed', true, 'adjustments_created', 0);
|
|
11076
|
+
END IF;
|
|
11077
|
+
|
|
11078
|
+
FOR v_item IN
|
|
11079
|
+
SELECT * FROM public.plg_inventory_count_items
|
|
11080
|
+
WHERE session_id = p_session_id AND movement_id IS NULL
|
|
11081
|
+
ORDER BY id
|
|
11082
|
+
FOR UPDATE
|
|
11083
|
+
LOOP
|
|
11084
|
+
SELECT coalesce(sum(p.quantity), 0),
|
|
11085
|
+
coalesce(max(p.unit_cost), v_item.unit_cost, 0),
|
|
11086
|
+
coalesce(bool_or((p.batch_number IS NOT NULL OR p.expiration_date IS NOT NULL) AND p.quantity <> 0), false)
|
|
11087
|
+
INTO v_current, v_current_cost, v_has_lots
|
|
11088
|
+
FROM public.plg_inventory_stock_positions p
|
|
11089
|
+
WHERE p.tenant_id = v_tenant
|
|
11090
|
+
AND p.product_id = v_item.product_id
|
|
11091
|
+
AND p.stock_location_id = v_session.stock_location_id;
|
|
11092
|
+
|
|
11093
|
+
v_delta := v_item.counted_quantity - v_current;
|
|
11094
|
+
IF v_delta = 0 THEN CONTINUE; END IF;
|
|
11095
|
+
IF v_has_lots THEN
|
|
11096
|
+
RAISE EXCEPTION 'inventory: product % has batch stock; close it with a batch-aware count', v_item.product_id
|
|
11097
|
+
USING ERRCODE = '22023';
|
|
11098
|
+
END IF;
|
|
11099
|
+
|
|
11100
|
+
v_line_no := v_line_no + 1;
|
|
11101
|
+
INSERT INTO public.plg_inventory_stock_movements (
|
|
11102
|
+
tenant_id, product_id, kind, quantity, unit_cost,
|
|
11103
|
+
source_location_id, document_type, reason,
|
|
11104
|
+
idempotency_key, line_no, operation_id,
|
|
11105
|
+
source_item_type, source_item_id, metadata
|
|
11106
|
+
) VALUES (
|
|
11107
|
+
v_tenant, v_item.product_id, 'adjust', v_delta, v_current_cost,
|
|
11108
|
+
v_session.stock_location_id, 'stock_count', v_reason,
|
|
11109
|
+
v_key, v_line_no, v_op.op_id,
|
|
11110
|
+
'stock_count_item', v_item.id,
|
|
11111
|
+
jsonb_build_object(
|
|
11112
|
+
'countSessionId', p_session_id,
|
|
11113
|
+
'countItemId', v_item.id,
|
|
11114
|
+
'systemQuantity', v_item.system_quantity,
|
|
11115
|
+
'currentQuantity', v_current,
|
|
11116
|
+
'countedQuantity', v_item.counted_quantity,
|
|
11117
|
+
'variance', v_delta
|
|
11118
|
+
)
|
|
11119
|
+
)
|
|
11120
|
+
RETURNING id INTO v_movement_id;
|
|
11121
|
+
|
|
11122
|
+
UPDATE public.plg_inventory_count_items
|
|
11123
|
+
SET movement_id = v_movement_id, updated_at = now()
|
|
11124
|
+
WHERE id = v_item.id;
|
|
11125
|
+
v_movements := v_movements || app.inventory_movement_json(v_movement_id);
|
|
11126
|
+
v_positions := v_positions || app.inventory_position_json(
|
|
11127
|
+
v_tenant, v_item.product_id, v_session.stock_location_id, NULL, NULL);
|
|
11128
|
+
v_emitted := v_emitted + 1;
|
|
11129
|
+
END LOOP;
|
|
11130
|
+
|
|
11131
|
+
UPDATE public.plg_inventory_count_sessions
|
|
11132
|
+
SET status = 'closed', closed_at = now(), closed_by = auth.uid(), updated_at = now()
|
|
11133
|
+
WHERE id = p_session_id;
|
|
11134
|
+
|
|
11135
|
+
v_result := jsonb_build_object(
|
|
11136
|
+
'ok', true, 'session_id', p_session_id, 'status', 'closed',
|
|
11137
|
+
'already_closed', false, 'adjustments_created', v_emitted,
|
|
11138
|
+
'operation_id', v_op.op_id, 'idempotency_key', v_key,
|
|
11139
|
+
'movements', v_movements, 'positions', v_positions);
|
|
11140
|
+
RETURN app.inventory_finish_operation(
|
|
11141
|
+
v_tenant, v_op.op_id, 'count.close', v_key, v_result,
|
|
11142
|
+
jsonb_build_object('count_session_id', p_session_id, 'adjustments_created', v_emitted));
|
|
11143
|
+
END;
|
|
11144
|
+
$$;
|
|
11145
|
+
`;
|
|
10886
11146
|
var MIGRATIONS = [
|
|
10887
11147
|
{ id: "000_baseline", sql: MIGRATION_000_BASELINE },
|
|
10888
11148
|
{ id: "001_the_usage_that_left_stock_can_be_audited", sql: MIGRATION_001_THE_USAGE_THAT_LEFT_STOCK_CAN_BE_AUDITED },
|
|
10889
|
-
{ id: "002_a_auditoria_fecha_em_lote", sql: MIGRATION_002_A_AUDITORIA_FECHA_EM_LOTE }
|
|
11149
|
+
{ id: "002_a_auditoria_fecha_em_lote", sql: MIGRATION_002_A_AUDITORIA_FECHA_EM_LOTE },
|
|
11150
|
+
{ id: "003_stock_count_closes_through_ledger", sql: MIGRATION_003_STOCK_COUNT_CLOSES_THROUGH_LEDGER }
|
|
10890
11151
|
];
|
|
10891
11152
|
|
|
10892
11153
|
// src/index.ts
|