1df3943d29
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.
211 lines
6.8 KiB
TypeScript
211 lines
6.8 KiB
TypeScript
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>
|
||
);
|
||
}
|