Fas 2 steg 4: Quick Reconciliation (motor, API, app + 12-språks i18n)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { View } from "react-native";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Tag,
|
||||
Title,
|
||||
} from "@/components/ui";
|
||||
import { spacing } from "@/lib/theme";
|
||||
import { colors, spacing } from "@/lib/theme";
|
||||
import { formatQuantity } from "@/lib/units";
|
||||
|
||||
/** Hemma (spec §4.4): matlager, bäst före, matlådor, inköpslista, budget, hushåll. */
|
||||
@@ -70,9 +70,11 @@ export default function HomeScreen() {
|
||||
<Screen>
|
||||
<Title>{t("home.title")}</Title>
|
||||
{trustStatus && (
|
||||
<Small style={{ marginBottom: spacing.xs, opacity: 0.8 }}>
|
||||
{t(`home.trustStatus.${trustStatus}`)}
|
||||
</Small>
|
||||
<Pressable onPress={() => router.push("/reconciliation")}>
|
||||
<Small style={{ marginBottom: spacing.xs, opacity: 0.8, color: colors.primary }}>
|
||||
{t(`home.trustStatus.${trustStatus}`)}
|
||||
</Small>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<Row>
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useState } from "react";
|
||||
import { ActivityIndicator, 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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user