Fas 2 steg 5: Scan-to-scan-diff + konfliktlösning, samt buggfix depleted-transaktion i reconciliation
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user