Files
Cibello-app/apps/mobile/src/app/reconciliation.tsx
T
Sven (AAMOS AI) 491daace86
CI / Typecheck, test & build (push) Successful in 1m37s
fix(mobile): onError på 15 mutationer – inga tysta miss (MIKRO 52)
2026-08-14 18:03:36 +07:00

182 lines
5.5 KiB
TypeScript

import { useState } from "react";
import { ActivityIndicator, Alert, View } from "react-native";
import { router } 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,
Input,
LoadingView,
Row,
Screen,
Small,
Spacer,
Tag,
Title,
} from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
import { formatQuantity } from "@/lib/units";
interface Candidate {
itemId: string;
displayName: string;
quantity: number;
unit: string;
locationName: string;
reasons: Array<
| { kind: "planned_recipe"; recipeIds: string[] }
| { kind: "expiring_soon"; daysLeft: number | null }
| { kind: "high_value"; priceMinor: number }
| { kind: "low_confidence"; confidence: number }
| { kind: "stale_trust"; trustState: string }
| { kind: "likely_depleted"; remainingDays: number }
>;
suggestedAction: "exists" | "depleted" | "uncertain";
suggestedQuantity?: number;
}
export default function ReconciliationScreen() {
const [index, setIndex] = useState(0);
const [adjustment, setAdjustment] = useState<string>("");
const query = useQuery({
queryKey: ["reconciliation-candidates"],
queryFn: () =>
api<{ candidates: Candidate[] }>("/v1/reconciliations/start", { method: "POST", body: {} }),
});
const resolveMutation = useMutation({
mutationFn: (input: {
itemId: string;
action: Candidate["suggestedAction"];
quantity?: number;
}) =>
api<{ itemId: string; action: string; quantity: number; verifiedByUser: boolean }>(
`/v1/reconciliations/items/${input.itemId}/resolve`,
{ method: "POST", body: { action: input.action, quantity: input.quantity } },
),
onSuccess: () => {
if (index < (query.data?.candidates.length ?? 0) - 1) {
setIndex((i) => i + 1);
setAdjustment("");
} else {
router.back();
}
},
onError: (err) =>
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
});
if (query.isLoading) return <LoadingView />;
if (query.isError) return <ErrorView onRetry={() => void query.refetch()} />;
const candidates = query.data?.candidates ?? [];
if (candidates.length === 0) {
return (
<Screen>
<Title>{t("reconciliation.title")}</Title>
<EmptyState text={t("reconciliation.empty")} />
<Button label={t("common.back")} variant="secondary" onPress={() => router.back()} />
</Screen>
);
}
const candidate = candidates[index];
const progress = `${index + 1}/${candidates.length}`;
function handleResolve(action: Candidate["suggestedAction"]) {
const qty = adjustment.trim() === "" ? undefined : parseFloat(adjustment.replace(",", "."));
resolveMutation.mutate({ itemId: candidate!.itemId, action, quantity: qty });
}
return (
<Screen>
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
<Title>{t("reconciliation.title")}</Title>
<Small>{progress}</Small>
</Row>
<Small>{t("reconciliation.subtitle")}</Small>
<Spacer size={spacing.sm} />
<Card>
<Row style={{ justifyContent: "space-between" }}>
<Heading>{candidate!.displayName}</Heading>
<Body>{formatQuantity(candidate!.quantity, candidate!.unit)}</Body>
</Row>
<Small>{candidate!.locationName}</Small>
<Spacer size={spacing.xs} />
<Row style={{ flexWrap: "wrap" }}>
{candidate!.reasons.map((reason, i) => (
<Tag key={i} label={reasonLabel(reason)} tone="neutral" />
))}
</Row>
</Card>
<Card>
<Body>{t("reconciliation.adjustQuantity")}</Body>
<Input
placeholder={formatQuantity(candidate!.quantity, candidate!.unit)}
value={adjustment}
onChangeText={setAdjustment}
keyboardType="decimal-pad"
/>
</Card>
<Small style={{ opacity: 0.8 }}>{t("reconciliation.tasteHint")}</Small>
<View style={{ flex: 1 }} />
<Row>
<Button
label={t("reconciliation.depleted")}
variant="secondary"
onPress={() => handleResolve("depleted")}
disabled={resolveMutation.isPending}
/>
<Button
label={t("reconciliation.uncertain")}
variant="secondary"
onPress={() => handleResolve("uncertain")}
disabled={resolveMutation.isPending}
/>
<Button
label={t("reconciliation.exists")}
onPress={() => handleResolve("exists")}
disabled={resolveMutation.isPending}
/>
</Row>
{resolveMutation.isPending && (
<ActivityIndicator color={colors.primary} style={{ marginTop: spacing.sm }} />
)}
</Screen>
);
}
function reasonLabel(reason: Candidate["reasons"][number]): string {
switch (reason.kind) {
case "planned_recipe":
return t("reconciliation.reason.planned_recipe");
case "expiring_soon":
return reason.daysLeft == null
? t("reconciliation.reason.expiring_soon")
: `${t("reconciliation.reason.expiring_soon")} (${reason.daysLeft}d)`;
case "high_value":
return t("reconciliation.reason.high_value");
case "low_confidence":
return t("reconciliation.reason.low_confidence");
case "stale_trust":
return t("reconciliation.reason.stale_trust");
case "likely_depleted":
return t("reconciliation.reason.likely_depleted");
default:
return "";
}
}