Files
Cibello-app/apps/mobile/src/app/kitchen.tsx
T
Claude 1df3943d29 fix(recept): uppdatera "Vad ska vi ata?" automatiskt nar lagret andras
Nar man raderade varor i Matlager uppdaterades inte receptforslagen (coverage
stod kvar pa t.ex. 50% tills man tvang-reloadade appen). Orsak: radering
invaliderade inte ["what-to-eat"]-cachen. Cooking + scan-review gjorde redan
detta; nu foljer kitchen (radering), barcode, meal-boxes och shopping samma
monster sa forslagen alltid speglar aktuellt lager.
2026-08-18 16:28:31 +00:00

211 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { useMemo, useState } from "react";
import { Alert, View } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import { formatQuantity } from "@/lib/units";
import {
Body,
Button,
Card,
EmptyState,
ErrorView,
Heading,
Input,
LoadingView,
Row,
Screen,
Small,
} from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
/**
* Matlager: allt du har hemma, städat per plats (Kyl/Frys/Skafferi …).
* Sök, filtrera på plats, markera flera och ta bort. Radering = mjuk
* (kvantitet → 0, historik bevaras).
*/
interface InventoryItem {
id: string;
displayName: string;
quantity: number;
unit: string;
locationName: string;
locationType: string;
expiry: { status: string; daysLeft: number | null; pastBestBefore: boolean };
}
const LOCATION_ORDER = ["fridge", "freezer", "pantry", "garage_freezer", "wine_fridge"];
const LOCATION_LABELS: Record<string, string> = {
fridge: "Kyl",
freezer: "Frys",
pantry: "Skafferi",
garage_freezer: "Garagefrys",
wine_fridge: "Vinkyl",
};
const locLabel = (type: string) => LOCATION_LABELS[type] ?? type;
const orderIndex = (type: string) => {
const i = LOCATION_ORDER.indexOf(type);
return i === -1 ? LOCATION_ORDER.length : i;
};
export default function KitchenScreen() {
const queryClient = useQueryClient();
const params = useLocalSearchParams<{ loc?: string }>();
const initialLoc = typeof params.loc === "string" ? params.loc : null;
const [search, setSearch] = useState("");
const [locFilter, setLocFilter] = useState<string | null>(initialLoc);
const [selected, setSelected] = useState<Set<string>>(new Set());
const query = useQuery({
queryKey: ["inventory", "all"],
queryFn: () => api<{ items: InventoryItem[] }>("/v1/inventory?limit=500"),
});
const bulkRemove = useMutation({
mutationFn: (ids: string[]) =>
api<{ deleted: number }>("/v1/inventory/bulk-delete", { method: "POST", body: { ids } }),
onSuccess: () => setSelected(new Set()),
onError: (err) =>
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: ["inventory"] });
void queryClient.invalidateQueries({ queryKey: ["inventory-expiring"] });
// Lagret styr receptförslagen uppdatera "Vad ska vi äta?" direkt.
void queryClient.invalidateQueries({ queryKey: ["what-to-eat"] });
},
});
const all = query.data?.items ?? [];
const locTypes = useMemo(() => {
const set = new Set(all.map((i) => i.locationType));
return [...set].sort((a, b) => orderIndex(a) - orderIndex(b) || a.localeCompare(b, "sv"));
}, [all]);
const q = search.trim().toLowerCase();
const visible = useMemo(
() =>
all
.filter((i) => (locFilter ? i.locationType === locFilter : true))
.filter((i) => (q ? i.displayName.toLowerCase().includes(q) : true)),
[all, locFilter, q],
);
const groups = useMemo(() => {
const map = new Map<string, InventoryItem[]>();
for (const it of visible) {
const list = map.get(it.locationType) ?? [];
list.push(it);
map.set(it.locationType, list);
}
return [...map.entries()]
.sort(([a], [b]) => orderIndex(a) - orderIndex(b) || a.localeCompare(b, "sv"))
.map(([type, items]) => ({
type,
items: [...items].sort((x, y) => x.displayName.localeCompare(y.displayName, "sv")),
}));
}, [visible]);
if (query.isLoading) return <LoadingView />;
if (query.isError) return <ErrorView onRetry={() => void query.refetch()} />;
const toggle = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const confirmDelete = (ids: string[], label: string) => {
if (ids.length === 0) return;
Alert.alert(
"Ta bort",
`Är du säker på att du vill ta bort ${ids.length} ${ids.length === 1 ? "vara" : "varor"} ur ${label}?`,
[
{ text: t("common.cancel"), style: "cancel" },
{ text: "Ta bort", style: "destructive", onPress: () => bulkRemove.mutate(ids) },
],
);
};
const emptyLabel = locFilter ? locLabel(locFilter) : q ? "sökningen" : "hela lagret";
return (
<Screen>
<Input value={search} onChangeText={setSearch} placeholder="Sök vara…" autoCorrect={false} />
<Row>
<Button
label="Alla"
variant={locFilter === null ? "primary" : "ghost"}
onPress={() => setLocFilter(null)}
/>
{locTypes.map((tp) => (
<Button
key={tp}
label={locLabel(tp)}
variant={locFilter === tp ? "primary" : "ghost"}
onPress={() => setLocFilter(tp)}
/>
))}
</Row>
<Row style={{ justifyContent: "space-between" }}>
<Small>
{visible.length} varor{selected.size > 0 ? ` · ${selected.size} valda` : ""}
</Small>
<Row style={{ justifyContent: "flex-end" }}>
{selected.size > 0 && (
<Button
label={`Ta bort valda (${selected.size})`}
variant="danger"
onPress={() => confirmDelete([...selected], "markeringen")}
/>
)}
{visible.length > 0 && (
<Button
label="Töm listan"
variant="ghost"
onPress={() => confirmDelete(visible.map((i) => i.id), emptyLabel)}
/>
)}
</Row>
</Row>
{visible.length === 0 && (
<EmptyState text="Inga varor att visa. Skanna eller lägg till varor." />
)}
{groups.map((g) => (
<View key={g.type} style={{ gap: spacing.xs, marginTop: spacing.sm }}>
<Row style={{ justifyContent: "space-between" }}>
<Heading>{locLabel(g.type)}</Heading>
<Small>{g.items.length} st</Small>
</Row>
{g.items.map((item) => {
const isSel = selected.has(item.id);
return (
<Card
key={item.id}
onPress={() => toggle(item.id)}
style={isSel ? { borderColor: colors.primary, backgroundColor: colors.primarySoft } : undefined}
>
<Row style={{ justifyContent: "space-between" }}>
<Body>
{isSel ? "☑ " : "☐ "}
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
</Body>
{item.expiry.pastBestBefore && <Small> bäst före</Small>}
</Row>
</Card>
);
})}
</View>
))}
</Screen>
);
}