Fas 2 steg 4: Quick Reconciliation (motor, API, app + 12-språks i18n)

This commit is contained in:
Sven (AAMOS AI)
2026-08-07 01:02:08 +07:00
parent 4385fe5125
commit 218bc44d08
24 changed files with 1069 additions and 17 deletions
+7 -5
View File
@@ -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>
+176
View File
@@ -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 "";
}
}
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Primært",
"home.trustStatus.up_to_date": "Opdateret",
"home.trustStatus.needs_check": "Nogle varer skal tjekkes",
"home.trustStatus.uncertain": "Lageret er usikkert lav en hurtig tjek"
"home.trustStatus.uncertain": "Lageret er usikkert lav en hurtig tjek",
"reconciliation.title": "Hurtig lagerkontrol",
"reconciliation.subtitle": "Bekræft, hvad du har tilbage, for at få bedre forslag.",
"reconciliation.exists": "Har stadig",
"reconciliation.depleted": "Det er brugt op",
"reconciliation.uncertain": "Usikker",
"reconciliation.adjustQuantity": "Juster mængde",
"reconciliation.reason.planned_recipe": "Skal bruges snart til en opskrift",
"reconciliation.reason.expiring_soon": "Udløber snart",
"reconciliation.reason.high_value": "Høj værdi",
"reconciliation.reason.low_confidence": "Anslået mængde",
"reconciliation.reason.stale_trust": "Ikke tjekket længe",
"reconciliation.reason.likely_depleted": "Sandsynligvis snart brugt op",
"reconciliation.done": "Færdig",
"reconciliation.next": "Næste",
"reconciliation.empty": "Intet behøver kontrol lige nu. Godt arbejde!",
"reconciliation.tasteHint": "Overskredet mindstholdbarhedsdato? lugt og smag først smid aldrig mad ud unødigt."
}
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Primär",
"home.trustStatus.up_to_date": "Aktuell",
"home.trustStatus.needs_check": "Einige Artikel müssen geprüft werden",
"home.trustStatus.uncertain": "Bestand unsicher schnelle Kontrolle empfohlen"
"home.trustStatus.uncertain": "Bestand unsicher schnelle Kontrolle empfohlen",
"reconciliation.title": "Schnelle Bestandskontrolle",
"reconciliation.subtitle": "Bestätige, was noch vorhanden ist, um bessere Vorschläge zu erhalten.",
"reconciliation.exists": "Noch vorhanden",
"reconciliation.depleted": "Ist aufgebraucht",
"reconciliation.uncertain": "Unsicher",
"reconciliation.adjustQuantity": "Menge anpassen",
"reconciliation.reason.planned_recipe": "Bald für ein Rezept benötigt",
"reconciliation.reason.expiring_soon": "Läuft bald ab",
"reconciliation.reason.high_value": "Hoher Wert",
"reconciliation.reason.low_confidence": "Geschätzte Menge",
"reconciliation.reason.stale_trust": "Schon lange nicht kontrolliert",
"reconciliation.reason.likely_depleted": "Wahrscheinlich bald aufgebraucht",
"reconciliation.done": "Fertig",
"reconciliation.next": "Weiter",
"reconciliation.empty": "Aktuell muss nichts kontrolliert werden. Gut gemacht!",
"reconciliation.tasteHint": "Mindesthaltbarkeitsdatum überschritten? Zuerst riechen und schmecken: niemals unbedingt Essen wegwerfen."
}
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Primary",
"home.trustStatus.up_to_date": "Up to date",
"home.trustStatus.needs_check": "Some items need checking",
"home.trustStatus.uncertain": "Inventory uncertain do a quick check"
"home.trustStatus.uncertain": "Inventory uncertain do a quick check",
"reconciliation.title": "Quick inventory check",
"reconciliation.subtitle": "Confirm what you have left to get better suggestions.",
"reconciliation.exists": "Still have it",
"reconciliation.depleted": "It is used up",
"reconciliation.uncertain": "Not sure",
"reconciliation.adjustQuantity": "Adjust amount",
"reconciliation.reason.planned_recipe": "Needed soon for a recipe",
"reconciliation.reason.expiring_soon": "Expires soon",
"reconciliation.reason.high_value": "High value",
"reconciliation.reason.low_confidence": "Estimated amount",
"reconciliation.reason.stale_trust": "Has not been checked in a while",
"reconciliation.reason.likely_depleted": "Likely used up soon",
"reconciliation.done": "Done",
"reconciliation.next": "Next",
"reconciliation.empty": "Nothing needs checking right now. Well done!",
"reconciliation.tasteHint": "Past best before? Smell and taste first never waste food unnecessarily."
}
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Principal",
"home.trustStatus.up_to_date": "Actualizado",
"home.trustStatus.needs_check": "Algunos productos necesitan revisión",
"home.trustStatus.uncertain": "Inventario incierto haz una revisión rápida"
"home.trustStatus.uncertain": "Inventario incierto haz una revisión rápida",
"reconciliation.title": "Revisión rápida del inventario",
"reconciliation.subtitle": "Confirma lo que te queda para obtener mejores sugerencias.",
"reconciliation.exists": "Todavía lo tengo",
"reconciliation.depleted": "Se ha acabado",
"reconciliation.uncertain": "No estoy seguro",
"reconciliation.adjustQuantity": "Ajustar cantidad",
"reconciliation.reason.planned_recipe": "Se necesita pronto para una receta",
"reconciliation.reason.expiring_soon": "Caduca pronto",
"reconciliation.reason.high_value": "Alto valor",
"reconciliation.reason.low_confidence": "Cantidad estimada",
"reconciliation.reason.stale_trust": "No se ha comprobado en mucho tiempo",
"reconciliation.reason.likely_depleted": "Probablemente se acabe pronto",
"reconciliation.done": "Listo",
"reconciliation.next": "Siguiente",
"reconciliation.empty": "No hay nada que revisar ahora. ¡Bien hecho!",
"reconciliation.tasteHint": "¿Pasada la fecha de consumo preferente? Huele y prueba primero: nunca tires comida sin necesidad."
}
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Ensisijainen",
"home.trustStatus.up_to_date": "Ajan tasalla",
"home.trustStatus.needs_check": "Joitakin tuotteita on tarkistettava",
"home.trustStatus.uncertain": "Varasto epävarma tee pikatarkistus"
"home.trustStatus.uncertain": "Varasto epävarma tee pikatarkistus",
"reconciliation.title": "Nopea varaston tarkistus",
"reconciliation.subtitle": "Vahvista, mitä sinulla on jäljellä, jotta saat parempia ehdotuksia.",
"reconciliation.exists": "Vielä jäljellä",
"reconciliation.depleted": "Loppu",
"reconciliation.uncertain": "Epävarma",
"reconciliation.adjustQuantity": "Säädä määrää",
"reconciliation.reason.planned_recipe": "Tarvitaan pian reseptiin",
"reconciliation.reason.expiring_soon": "Vanhenee pian",
"reconciliation.reason.high_value": "Korkea arvo",
"reconciliation.reason.low_confidence": "Arvioitu määrä",
"reconciliation.reason.stale_trust": "Ei tarkistettu pitkään aikaan",
"reconciliation.reason.likely_depleted": "Todennäköisesti pian loppu",
"reconciliation.done": "Valmis",
"reconciliation.next": "Seuraava",
"reconciliation.empty": "Mitään ei tarvitse tarkistaa juuri nyt. Hyvää työtä!",
"reconciliation.tasteHint": "Parasta ennen -päiväys umpeutunut? Haista ja maista ensin älä koskaan heitä ruokaa turhaan."
}
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Principal",
"home.trustStatus.up_to_date": "À jour",
"home.trustStatus.needs_check": "Certains articles doivent être vérifiés",
"home.trustStatus.uncertain": "Inventaire incertain faites un rapide contrôle"
"home.trustStatus.uncertain": "Inventaire incertain faites un rapide contrôle",
"reconciliation.title": "Vérification rapide des stocks",
"reconciliation.subtitle": "Confirmez ce qui vous reste pour de meilleures suggestions.",
"reconciliation.exists": "Toujours en stock",
"reconciliation.depleted": "C'est fini",
"reconciliation.uncertain": "Incertain",
"reconciliation.adjustQuantity": "Ajuster la quantité",
"reconciliation.reason.planned_recipe": "Bientôt nécessaire pour une recette",
"reconciliation.reason.expiring_soon": "Expire bientôt",
"reconciliation.reason.high_value": "Haute valeur",
"reconciliation.reason.low_confidence": "Quantité estimée",
"reconciliation.reason.stale_trust": "Non vérifié depuis longtemps",
"reconciliation.reason.likely_depleted": "Probablement bientôt épuisé",
"reconciliation.done": "Terminé",
"reconciliation.next": "Suivant",
"reconciliation.empty": "Rien à vérifier pour l'instant. Bien joué !",
"reconciliation.tasteHint": "Dépasse la date de durabilité minimale ? Sentez et goûtez d'abord : ne gaspillez jamais de nourriture inutilement."
}
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Primario",
"home.trustStatus.up_to_date": "Aggiornato",
"home.trustStatus.needs_check": "Alcuni articoli devono essere controllati",
"home.trustStatus.uncertain": "Inventario incerto fai un controllo rapido"
"home.trustStatus.uncertain": "Inventario incerto fai un controllo rapido",
"reconciliation.title": "Controllo rapido inventario",
"reconciliation.subtitle": "Conferma cosa ti rimane per ricevere suggerimenti migliori.",
"reconciliation.exists": "Ancora disponibile",
"reconciliation.depleted": "È finito",
"reconciliation.uncertain": "Incerto",
"reconciliation.adjustQuantity": "Regola quantità",
"reconciliation.reason.planned_recipe": "Serve presto per una ricetta",
"reconciliation.reason.expiring_soon": "Scade presto",
"reconciliation.reason.high_value": "Alto valore",
"reconciliation.reason.low_confidence": "Quantità stimata",
"reconciliation.reason.stale_trust": "Non controllato da tempo",
"reconciliation.reason.likely_depleted": "Probabilmente finirà presto",
"reconciliation.done": "Fatto",
"reconciliation.next": "Avanti",
"reconciliation.empty": "Non c'è nulla da controllare. Ottimo lavoro!",
"reconciliation.tasteHint": "Scadenza preferibile superata? Odora e assaggia prima: non buttare mai cibo inutilmente."
}
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Primært",
"home.trustStatus.up_to_date": "Oppdatert",
"home.trustStatus.needs_check": "Noen varer må sjekkes",
"home.trustStatus.uncertain": "Lageret er usikkert gjør en rask sjekk"
"home.trustStatus.uncertain": "Lageret er usikkert gjør en rask sjekk",
"reconciliation.title": "Hurtig lagerkontroll",
"reconciliation.subtitle": "Bekreft hva du har igjen for å få bedre forslag.",
"reconciliation.exists": "Har fortsatt",
"reconciliation.depleted": "Det er brukt opp",
"reconciliation.uncertain": "Usikker",
"reconciliation.adjustQuantity": "Juster mengde",
"reconciliation.reason.planned_recipe": "Trengs snart til en oppskrift",
"reconciliation.reason.expiring_soon": "Går snart ut",
"reconciliation.reason.high_value": "Høy verdi",
"reconciliation.reason.low_confidence": "Anslått mengde",
"reconciliation.reason.stale_trust": "Ikke sjekket på lenge",
"reconciliation.reason.likely_depleted": "Sannsynligvis snart brukt opp",
"reconciliation.done": "Ferdig",
"reconciliation.next": "Neste",
"reconciliation.empty": "Ingenting trenger kontroll akkurat nå. Bra jobbet!",
"reconciliation.tasteHint": "Forbi best før-dato? Lukt og smak først kast aldri mat unødvendig."
}
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Primair",
"home.trustStatus.up_to_date": "Bijgewerkt",
"home.trustStatus.needs_check": "Sommige items moeten worden gecontroleerd",
"home.trustStatus.uncertain": "Voorraad onzeker doe een snelle controle"
"home.trustStatus.uncertain": "Voorraad onzeker doe een snelle controle",
"reconciliation.title": "Snelle voorraadcheck",
"reconciliation.subtitle": "Bevestig wat je nog hebt voor betere suggesties.",
"reconciliation.exists": "Nog op voorraad",
"reconciliation.depleted": "Het is op",
"reconciliation.uncertain": "Onzeker",
"reconciliation.adjustQuantity": "Hoeveelheid aanpassen",
"reconciliation.reason.planned_recipe": "Binnenkort nodig voor een recept",
"reconciliation.reason.expiring_soon": "Binnenkort houdbaarheidsdatum",
"reconciliation.reason.high_value": "Hoge waarde",
"reconciliation.reason.low_confidence": "Geschatte hoeveelheid",
"reconciliation.reason.stale_trust": "Al lang niet gecontroleerd",
"reconciliation.reason.likely_depleted": "Waarschijnlijk binnenkort op",
"reconciliation.done": "Klaar",
"reconciliation.next": "Volgende",
"reconciliation.empty": "Er is momenteel niets om te controleren. Goed gedaan!",
"reconciliation.tasteHint": "Tenminste houdbaar tot verstreken? Ruik en proef eerst gooi nooit onnodig eten weg."
}
+17 -1
View File
@@ -363,5 +363,21 @@
"onboarding.primaryGoal": "Główny",
"home.trustStatus.up_to_date": "Aktualne",
"home.trustStatus.needs_check": "Niektóre produkty wymagają sprawdzenia",
"home.trustStatus.uncertain": "Stan niepewny zrób szybką kontrolę"
"home.trustStatus.uncertain": "Stan niepewny zrób szybką kontrolę",
"reconciliation.title": "Szybka kontrola zapasów",
"reconciliation.subtitle": "Potwierdź, co ci zostało, aby uzyskać lepsze propozycje.",
"reconciliation.exists": "Wciąż mam",
"reconciliation.depleted": "Skończyło się",
"reconciliation.uncertain": "Niepewny",
"reconciliation.adjustQuantity": "Dostosuj ilość",
"reconciliation.reason.planned_recipe": "Wkrótce potrzebne do przepisu",
"reconciliation.reason.expiring_soon": "Wkrótce traci ważność",
"reconciliation.reason.high_value": "Wysoka wartość",
"reconciliation.reason.low_confidence": "Szacowana ilość",
"reconciliation.reason.stale_trust": "Nie sprawdzane od dawna",
"reconciliation.reason.likely_depleted": "Prawdopodobnie wkrótce się skończy",
"reconciliation.done": "Gotowe",
"reconciliation.next": "Dalej",
"reconciliation.empty": "Teraz nic nie wymaga kontroli. Dobra robota!",
"reconciliation.tasteHint": "Po terminie przydatności do spożycia? Najpierw powąchaj i posmakuj nigdy nie wyrzucaj jedzenia bez potrzeby."
}
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Principal",
"home.trustStatus.up_to_date": "Atualizado",
"home.trustStatus.needs_check": "Alguns itens precisam de verificação",
"home.trustStatus.uncertain": "Inventário incerto faça uma verificação rápida"
"home.trustStatus.uncertain": "Inventário incerto faça uma verificação rápida",
"reconciliation.title": "Verificação rápida de stock",
"reconciliation.subtitle": "Confirme o que tem sobrando para obter melhores sugestões.",
"reconciliation.exists": "Ainda tenho",
"reconciliation.depleted": "Acabou",
"reconciliation.uncertain": "Incerto",
"reconciliation.adjustQuantity": "Ajustar quantidade",
"reconciliation.reason.planned_recipe": "Necessário em breve para uma receita",
"reconciliation.reason.expiring_soon": "Expira em breve",
"reconciliation.reason.high_value": "Alto valor",
"reconciliation.reason.low_confidence": "Quantidade estimada",
"reconciliation.reason.stale_trust": "Não verificado há algum tempo",
"reconciliation.reason.likely_depleted": "Provavelmente acaba em breve",
"reconciliation.done": "Concluído",
"reconciliation.next": "Próximo",
"reconciliation.empty": "Nada precisa de verificação agora. Muito bem!",
"reconciliation.tasteHint": "Passou do prazo de validade? Cheire e prove primeiro nunca desperdice comida desnecessariamente."
}
+17 -1
View File
@@ -349,5 +349,21 @@
"onboarding.primaryGoal": "Primärt",
"home.trustStatus.up_to_date": "Uppdaterat",
"home.trustStatus.needs_check": "Några varor behöver kontrolleras",
"home.trustStatus.uncertain": "Lagret är osäkert gör en snabbkoll"
"home.trustStatus.uncertain": "Lagret är osäkert gör en snabbkoll",
"reconciliation.title": "Snabbkoll av lagret",
"reconciliation.subtitle": "Bekräfta vad du har kvar så blir förslagen bättre.",
"reconciliation.exists": "Finns kvar",
"reconciliation.depleted": "Är slut",
"reconciliation.uncertain": "Osäker",
"reconciliation.adjustQuantity": "Justera mängd",
"reconciliation.reason.planned_recipe": "Behövs snart till recept",
"reconciliation.reason.expiring_soon": "Går snart ut",
"reconciliation.reason.high_value": "Högt värde",
"reconciliation.reason.low_confidence": "Uppskattad mängd",
"reconciliation.reason.stale_trust": "Har inte kontrollerats på länge",
"reconciliation.reason.likely_depleted": "Borde vara slut snart",
"reconciliation.done": "Klart",
"reconciliation.next": "Nästa",
"reconciliation.empty": "Inget behöver avstämning just nu. Bra jobbat!",
"reconciliation.tasteHint": "Passerat bäst före? Lukta och smaka först mat kastas inte i onödan."
}