Fas 2 steg 5: Scan-to-scan-diff + konfliktlösning, samt buggfix depleted-transaktion i reconciliation

This commit is contained in:
Sven (AAMOS AI)
2026-08-07 01:30:31 +07:00
parent 218bc44d08
commit 4a4f448e1c
26 changed files with 10590 additions and 13 deletions
@@ -0,0 +1,130 @@
import { useMemo, useState } from "react";
import { View } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import {
Body,
Button,
Card,
EmptyState,
ErrorView,
Heading,
LoadingView,
Row,
Screen,
Small,
Spacer,
Tag,
} from "@/components/ui";
import { spacing } from "@/lib/theme";
type DiffRow = {
kind: "new_item" | "moved" | "quantity_changed" | "vanished" | "unchanged";
previousItemId?: string;
displayName: string;
unit: string;
previousQuantity?: number;
newQuantity?: number;
previousLocationId?: string;
newLocationId?: string;
confidence: number;
reason: string;
};
export default function ScanDiffReviewScreen() {
const { jobId } = useLocalSearchParams<{ jobId: string }>();
const [rejectedKinds] = useState<Set<string>>(new Set());
const query = useQuery({
queryKey: ["scan-diff", jobId],
queryFn: () => api<{ rows: DiffRow[] }>(`/v1/scans/${jobId}/diff`, { method: "POST", body: {} }),
});
const apply = useMutation({
mutationFn: (rows: DiffRow[]) =>
api<{ appliedItemIds: string[] }>(`/v1/scans/${jobId}/diff/apply`, {
method: "POST",
body: { idempotencyKey: `scan-diff-${jobId}-${Date.now()}`, rows },
}),
onSuccess: () => {
router.back();
},
});
const rows = useMemo(() => query.data?.rows ?? [], [query.data]);
const actionable = rows.filter((r) => r.kind !== "unchanged");
const accepted = actionable.filter((r) => !rejectedKinds.has(rowKey(r)));
if (query.isLoading) return <LoadingView />;
if (query.isError) return <ErrorView onRetry={() => void query.refetch()} />;
if (actionable.length === 0) {
return (
<Screen>
<Heading>{t("scan.diff.title")}</Heading>
<EmptyState text={t("scan.diff.noChanges")} />
<Button label={t("common.back")} variant="secondary" onPress={() => router.back()} />
</Screen>
);
}
return (
<Screen>
<Heading>{t("scan.diff.title")}</Heading>
<Small>{t("scan.diff.subtitle")}</Small>
<Spacer size={spacing.sm} />
{actionable.map((row) => (
<Card key={rowKey(row)}>
<Row style={{ justifyContent: "space-between" }}>
<Body>{row.displayName}</Body>
<Tag label={t(`scan.diff.${row.kind}`)} tone={toneForKind(row.kind)} />
</Row>
<Small>{row.reason}</Small>
{(row.kind === "quantity_changed" || row.kind === "moved") && (
<Small>
{formatQty(row.previousQuantity)} {formatQty(row.newQuantity)} {row.unit}
</Small>
)}
{row.kind === "vanished" && (
<Small style={{ opacity: 0.8 }}>{t("reconciliation.tasteHint")}</Small>
)}
</Card>
))}
<Small style={{ opacity: 0.7 }}>{t("scan.diff.undoHint")}</Small>
<Spacer size={spacing.sm} />
<Button
label={t("scan.diff.acceptAll")}
loading={apply.isPending}
onPress={() => apply.mutate(accepted)}
/>
</Screen>
);
}
function rowKey(row: DiffRow): string {
return `${row.kind}:${row.previousItemId ?? "new"}:${row.displayName}`;
}
function toneForKind(kind: DiffRow["kind"]) {
switch (kind) {
case "vanished":
return "warning";
case "new_item":
return "success";
case "quantity_changed":
case "moved":
return "accent";
default:
return "neutral";
}
}
function formatQty(q?: number): string {
if (q == null) return "-";
return Number.isInteger(q) ? String(q) : q.toFixed(1);
}