Files
Cibello-app/apps/mobile/src/app/scan-review/[jobId].tsx
T

269 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { Alert, View } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import {
Body,
Button,
Card,
ErrorView,
Heading,
Input,
LoadingView,
Row,
Screen,
Small,
Spacer,
Tag,
} from "@/components/ui";
import { spacing } from "@/lib/theme";
import { useAnalytics } from "@/lib/analytics";
import { scanReviewOpened } from "@app/analytics";
import { parseUnitInput, unitLabel } from "@/lib/units";
/**
* Granska AI-resultat (spec §10, §61.45): användaren godkänner, ändrar,
* tar bort och lägger till INNAN något skrivs till Food Twin.
* Osäkra rader flaggas tydligt. Ändringar blir ai_corrections → AAMOS-träning.
*/
interface DetectedItem {
tempId?: string;
detectedName: string;
canonicalIngredientId: string | null;
brand: string | null;
estimatedQuantity: number | null;
unit: string | null;
bestBeforeDate: string | null;
confidence: number;
requiresConfirmation: boolean;
}
interface ScanJob {
id: string;
status: string;
scanType: string;
error: string | null;
result: { items?: DetectedItem[] } | null;
}
interface EditableItem {
tempId: string;
original: DetectedItem | null;
name: string;
quantity: string;
unit: string;
canonicalIngredientId: string | null;
date: string;
/**
* Datumtyp (spec §13): "Bäst före" är en KVALITETSgräns (varan kan vara god
* längre lukta och smaka), "Sista förbrukningsdag" en SÄKERHETSgräns.
* Användaren väljer typ så att appen aldrig dömer mat i onödan och aldrig
* mjukar upp en riktig säkerhetsgräns.
*/
dateIsUseBy: boolean;
confidence: number;
rejected: boolean;
}
export default function ScanReviewScreen() {
const { jobId } = useLocalSearchParams<{ jobId: string }>();
const queryClient = useQueryClient();
const [items, setItems] = useState<EditableItem[] | null>(null);
const [busy, setBusy] = useState(false);
const { track } = useAnalytics();
useEffect(() => {
if (jobId) track(scanReviewOpened({ properties: { jobId } }));
}, [jobId]); // eslint-disable-line react-hooks/exhaustive-deps
const query = useQuery({
queryKey: ["scan", jobId],
queryFn: () => api<ScanJob>(`/v1/scans/${jobId}`),
refetchInterval: (q) => {
const status = q.state.data?.status;
return status === "queued" || status === "running" ? 1500 : false;
},
});
const job = query.data;
// Initiera redigerbara rader när resultatet landar
useEffect(() => {
if (job?.status === "awaiting_confirmation" && job.result?.items && items == null) {
setItems(
job.result.items.map((item, index) => ({
tempId: item.tempId ?? `item-${index}`,
original: item,
name: item.detectedName,
quantity: item.estimatedQuantity != null ? String(item.estimatedQuantity) : "1",
unit: unitLabel(item.unit ?? "COUNT"),
canonicalIngredientId: item.canonicalIngredientId,
date: item.bestBeforeDate ?? "",
dateIsUseBy: false,
confidence: item.confidence,
rejected: false,
})),
);
}
}, [job, items]);
const update = (tempId: string, patch: Partial<EditableItem>) =>
setItems((prev) => prev?.map((i) => (i.tempId === tempId ? { ...i, ...patch } : i)) ?? null);
const addManual = () =>
setItems((prev) => [
...(prev ?? []),
{
tempId: `manual-${Date.now()}`,
original: null,
name: "",
quantity: "1",
unit: unitLabel("COUNT"),
canonicalIngredientId: null,
date: "",
dateIsUseBy: false,
confidence: 1,
rejected: false,
},
]);
const confirm = async () => {
if (!items) return;
setBusy(true);
try {
const payload = items
.filter((i) => i.rejected || i.name.trim().length > 0)
.map((i) => {
const edited =
i.original == null ||
i.name !== i.original.detectedName ||
Number(i.quantity) !== i.original.estimatedQuantity ||
i.unit !== (i.original.unit ?? "st");
return {
tempId: i.tempId,
action: i.rejected ? "reject" : i.original == null ? "add" : edited ? "edit" : "accept",
displayName: i.name.trim() || i.original?.detectedName || t("scan.review.unknownItem"),
canonicalIngredientId: i.canonicalIngredientId ?? undefined,
quantity: Math.max(0.01, Number(i.quantity) || 1),
unit: parseUnitInput(i.unit) ?? "COUNT",
// Rätt kolumn per datumtyp motorn behandlar dem helt olika (spec §13).
bestBeforeDate: !i.dateIsUseBy && i.date ? i.date : undefined,
useByDate: i.dateIsUseBy && i.date ? i.date : undefined,
};
});
await api(`/v1/scans/${jobId}/confirm`, { method: "POST", body: { items: payload } });
await queryClient.invalidateQueries({ queryKey: ["inventory"] });
await queryClient.invalidateQueries({ queryKey: ["what-to-eat"] });
router.back();
} catch (err) {
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error"));
} finally {
setBusy(false);
}
};
if (query.isLoading || job?.status === "queued" || job?.status === "running") {
return (
<Screen scroll={false}>
<LoadingView
messages={[
t("scan.progress.analyzing"),
t("scan.progress.finding"),
t("scan.progress.matching"),
t("scan.progress.almost"),
]}
/>
<Body muted>{t("scan.analyzing")}</Body>
</Screen>
);
}
if (query.isError || !job) return <ErrorView onRetry={() => void query.refetch()} />;
if (job.status === "failed") {
return (
<Screen>
<ErrorView message={job.error ?? t("scan.failed")} onRetry={() => router.back()} />
</Screen>
);
}
return (
<Screen>
<Heading>{t("scan.review.title")}</Heading>
<Small>{t("scan.review.subtitle")}</Small>
<Spacer size={spacing.sm} />
{(items ?? []).map((item) => (
<Card key={item.tempId} style={item.rejected ? { opacity: 0.4 } : undefined}>
<Row style={{ justifyContent: "space-between" }}>
{item.confidence < 0.7 && !item.rejected ? (
<Tag label={`⚠️ ${t("scan.review.uncertain")}`} tone="warning" />
) : (
<View />
)}
<Button
label={item.rejected ? t("common.undo") : t("common.remove")}
variant="ghost"
onPress={() => update(item.tempId, { rejected: !item.rejected })}
/>
</Row>
{!item.rejected && (
<>
<Input
value={item.name}
onChangeText={(v) => update(item.tempId, { name: v })}
placeholder={t("scan.review.itemPlaceholder")}
/>
<Row>
<View style={{ flex: 1 }}>
<Input
value={item.quantity}
keyboardType="numeric"
onChangeText={(v) => update(item.tempId, { quantity: v })}
placeholder={t("scan.review.quantityPlaceholder")}
/>
</View>
<View style={{ flex: 1 }}>
<Input
value={item.unit}
onChangeText={(v) => update(item.tempId, { unit: v })}
placeholder={t("scan.review.unitPlaceholder")}
/>
</View>
</Row>
{/* Datumtyp (spec §13): bäst före = kvalitet, sista förbrukningsdag = säkerhet. */}
<Row>
<Button
label={t("scan.review.dateKindBestBefore")}
variant={item.dateIsUseBy ? "ghost" : "secondary"}
onPress={() => update(item.tempId, { dateIsUseBy: false })}
/>
<Button
label={t("scan.review.dateKindUseBy")}
variant={item.dateIsUseBy ? "secondary" : "ghost"}
onPress={() => update(item.tempId, { dateIsUseBy: true })}
/>
</Row>
<Input
value={item.date}
onChangeText={(v) => update(item.tempId, { date: v })}
placeholder={t("scan.review.datePlaceholder")}
/>
{item.date.length > 0 && (
<Small>
{item.dateIsUseBy ? t("scan.review.useByNote") : t("scan.review.bestBeforeNote")}
</Small>
)}
</>
)}
</Card>
))}
<Button label={t("scan.review.addItem")} variant="ghost" onPress={addManual} />
<Spacer size={spacing.sm} />
<Button label={t("scan.review.approveAll")} onPress={() => void confirm()} loading={busy} />
</Screen>
);
}