fix(matlager): stada upp – flytta lager till egen vy, gruppera per plats, fixa limit-bugg
- BUGG: kitchen hamtade ?limit=500 men schemat tillat max 200 -> 400-fel nar man gick in i matlager / tryckte "+N till". Hojer inventoryQuerySchema.limit till 500. - Hem: tar bort den roriga inline-listan (matlager lag kvar under Hemma). Ersatts med ett "Matlager"-kort med genvagsknappar per plats (Kyl/Frys/Skafferi) + "Allt". - Matlager-skarmen: grupperad per plats med rubrik + antal, sorterad pa namn, plats-filter via chips ELLER djuplank (?loc=), sok, markera flera, massradering. - Byter skarmtitel "Ditt kok" -> "Matlager". - Fixar aven en latent hooks-ordningsbugg i home (useFocusEffect efter early return).
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Alert, Pressable, View } from "react-native";
|
||||
import { useCallback } from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { router, useFocusEffect } from "expo-router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { t } from "@/lib/i18n";
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Body,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorView,
|
||||
Heading,
|
||||
LoadingView,
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
Title,
|
||||
} from "@/components/ui";
|
||||
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. */
|
||||
|
||||
@@ -33,6 +31,7 @@ interface InventoryItem {
|
||||
quantity: number;
|
||||
unit: string;
|
||||
locationName: string;
|
||||
locationType: string;
|
||||
expiry: { status: string; daysLeft: number | null; pastBestBefore: boolean };
|
||||
}
|
||||
interface BudgetSummary {
|
||||
@@ -41,24 +40,18 @@ interface BudgetSummary {
|
||||
month: { purchasedMinor: number; wasteMinor: number };
|
||||
}
|
||||
|
||||
const LOCATION_ORDER = ["fridge", "freezer", "pantry", "garage_freezer", "wine_fridge"];
|
||||
const orderIndex = (type: string) => {
|
||||
const i = LOCATION_ORDER.indexOf(type);
|
||||
return i === -1 ? LOCATION_ORDER.length : i;
|
||||
};
|
||||
|
||||
export default function HomeScreen() {
|
||||
const queryClient = useQueryClient();
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => api(`/v1/inventory/items/${id}`, { method: "DELETE" }),
|
||||
onError: (err) =>
|
||||
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
|
||||
onSettled: () => void queryClient.invalidateQueries({ queryKey: ["inventory"] }),
|
||||
});
|
||||
const confirmRemove = (id: string, name: string) =>
|
||||
Alert.alert("Ta bort", `Ta bort ${name} ur ditt lager?`, [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{ text: "Ta bort", style: "destructive", onPress: () => remove.mutate(id) },
|
||||
]);
|
||||
const inventory = useQuery({
|
||||
queryKey: ["inventory"],
|
||||
queryFn: () =>
|
||||
api<{ items: InventoryItem[]; trustStatus?: "up_to_date" | "needs_check" | "uncertain" }>(
|
||||
"/v1/inventory?limit=100",
|
||||
"/v1/inventory?limit=200",
|
||||
),
|
||||
});
|
||||
const expiring = useQuery({
|
||||
@@ -76,21 +69,26 @@ export default function HomeScreen() {
|
||||
});
|
||||
const setOnboardingStep = useAuth((s) => s.setOnboardingStep);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void onboardingStatus.refetch();
|
||||
}, []),
|
||||
);
|
||||
|
||||
const items = inventory.data?.items ?? [];
|
||||
// Distinkta platser (Kyl/Frys/Skafferi …) för genvägsknappar in i Matlager.
|
||||
const locations = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const it of items) if (!seen.has(it.locationType)) seen.set(it.locationType, it.locationName);
|
||||
return [...seen.entries()]
|
||||
.map(([type, name]) => ({ type, name }))
|
||||
.sort((a, b) => orderIndex(a.type) - orderIndex(b.type));
|
||||
}, [items]);
|
||||
|
||||
if (inventory.isLoading) return <LoadingView />;
|
||||
if (inventory.isError) return <ErrorView onRetry={() => void inventory.refetch()} />;
|
||||
|
||||
useFocusEffect(useCallback(() => { void onboardingStatus.refetch(); }, []));
|
||||
|
||||
const items = inventory.data?.items ?? [];
|
||||
const urgent = expiring.data?.items ?? [];
|
||||
|
||||
const grouped = new Map<string, InventoryItem[]>();
|
||||
for (const item of items) {
|
||||
const list = grouped.get(item.locationName) ?? [];
|
||||
list.push(item);
|
||||
grouped.set(item.locationName, list);
|
||||
}
|
||||
|
||||
const trustStatus = inventory.data?.trustStatus;
|
||||
const needsProfile = onboardingStatus.data && !onboardingStatus.data.onboardingCompleted;
|
||||
const profileStep = onboardingStatus.data?.step ?? "b";
|
||||
@@ -130,10 +128,42 @@ export default function HomeScreen() {
|
||||
<Card onPress={() => router.push("/saved-recipes")}>
|
||||
<Body>⭐ Dina sparade recept</Body>
|
||||
</Card>
|
||||
<Card onPress={() => router.push("/kitchen")}>
|
||||
<Body>🧺 Ditt kök</Body>
|
||||
|
||||
{/* Matlager – städat, öppnas per plats eller som helhet (spec §8). */}
|
||||
<Card>
|
||||
<Heading>🧺 {t("home.inventory")}</Heading>
|
||||
<Small>Allt du har hemma – öppna en plats eller sök i hela lagret.</Small>
|
||||
<Row style={{ marginTop: spacing.xs }}>
|
||||
{locations.map((loc) => (
|
||||
<Button
|
||||
key={loc.type}
|
||||
label={loc.name}
|
||||
variant="ghost"
|
||||
onPress={() => router.push(`/kitchen?loc=${loc.type}`)}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
label={locations.length ? "Allt" : "Öppna"}
|
||||
variant="primary"
|
||||
onPress={() => router.push("/kitchen")}
|
||||
/>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{urgent.length > 0 && (
|
||||
<Card>
|
||||
<Heading>⏳ {t("home.useSoon")}</Heading>
|
||||
{urgent.slice(0, 5).map((item) => (
|
||||
<Row key={item.id} style={{ justifyContent: "space-between" }}>
|
||||
<Body>{item.displayName}</Body>
|
||||
<ExpiryTag expiry={item.expiry} />
|
||||
</Row>
|
||||
))}
|
||||
{/* Mjölkprincipen (spec §13): bäst före ≠ dålig – döm aldrig mat i onödan. */}
|
||||
{urgent.some((i) => i.expiry.pastBestBefore) && <Small>{t("home.useSoonHint")}</Small>}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{budget.data && (
|
||||
<Card>
|
||||
<Heading>{t("home.budget")}</Heading>
|
||||
@@ -153,47 +183,6 @@ export default function HomeScreen() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{urgent.length > 0 && (
|
||||
<Card>
|
||||
<Heading>⏳ {t("home.useSoon")}</Heading>
|
||||
{urgent.slice(0, 5).map((item) => (
|
||||
<Row key={item.id} style={{ justifyContent: "space-between" }}>
|
||||
<Body>{item.displayName}</Body>
|
||||
<ExpiryTag expiry={item.expiry} />
|
||||
</Row>
|
||||
))}
|
||||
{/* Mjölkprincipen (spec §13): bäst före ≠ dålig – döm aldrig mat i onödan. */}
|
||||
{urgent.some((i) => i.expiry.pastBestBefore) && <Small>{t("home.useSoonHint")}</Small>}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Heading>{t("home.inventory")}</Heading>
|
||||
{items.length === 0 && <EmptyState text={t("home.emptyInventory")} />}
|
||||
{[...grouped.entries()].map(([location, locationItems]) => (
|
||||
<Card key={location}>
|
||||
<Heading>{location}</Heading>
|
||||
{locationItems.slice(0, 12).map((item) => (
|
||||
<Row key={item.id} style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Body>
|
||||
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
|
||||
</Body>
|
||||
<Row style={{ alignItems: "center" }}>
|
||||
<ExpiryTag expiry={item.expiry} />
|
||||
<Button
|
||||
label="Ta bort"
|
||||
variant="ghost"
|
||||
onPress={() => confirmRemove(item.id, item.displayName)}
|
||||
/>
|
||||
</Row>
|
||||
</Row>
|
||||
))}
|
||||
{locationItems.length > 12 && (
|
||||
<Pressable onPress={() => router.push("/kitchen")}>
|
||||
<Small>{t("home.moreItems", { count: locationItems.length - 12 })} ›</Small>
|
||||
</Pressable>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
<Spacer />
|
||||
</Screen>
|
||||
);
|
||||
@@ -210,16 +199,16 @@ function QuickLink({
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
onPress={() => {
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
onPress();
|
||||
}}
|
||||
style={{ flex: 1, alignItems: "center", paddingVertical: spacing.sm }}
|
||||
>
|
||||
onPress={() => {
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
onPress();
|
||||
}}
|
||||
style={{ flex: 1, alignItems: "center", paddingVertical: spacing.sm }}
|
||||
>
|
||||
<Body>{glyph}</Body>
|
||||
<Small numberOfLines={1} adjustsFontSizeToFit style={{ textAlign: "center" }}>
|
||||
{label}
|
||||
</Small>
|
||||
{label}
|
||||
</Small>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="household" options={{ title: t("home.household"), presentation: "modal" }} />
|
||||
<Stack.Screen name="memory" options={{ title: t("memory.title") }} />
|
||||
<Stack.Screen name="saved-recipes" options={{ title: "Sparade recept" }} />
|
||||
<Stack.Screen name="kitchen" options={{ title: "Ditt kök" }} />
|
||||
<Stack.Screen name="kitchen" options={{ title: "Matlager" }} />
|
||||
<Stack.Screen name="profile" options={{ title: t("profile.title"), presentation: "modal" }} />
|
||||
<Stack.Screen
|
||||
name="paywall"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, TextInput } from "react-native";
|
||||
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";
|
||||
@@ -10,14 +11,20 @@ import {
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorView,
|
||||
Heading,
|
||||
Input,
|
||||
LoadingView,
|
||||
Row,
|
||||
Screen,
|
||||
Small,
|
||||
} from "@/components/ui";
|
||||
import { colors, radius, spacing } from "@/lib/theme";
|
||||
import { colors, spacing } from "@/lib/theme";
|
||||
|
||||
/** Ditt kök: sök, sortera, markera flera och ta bort. Radering = mjuk (kvantitet → 0, historik bevaras). */
|
||||
/**
|
||||
* 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;
|
||||
@@ -29,6 +36,7 @@ interface InventoryItem {
|
||||
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",
|
||||
@@ -37,11 +45,17 @@ const LOCATION_LABELS: Record<string, string> = {
|
||||
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>(null);
|
||||
const [locFilter, setLocFilter] = useState<string | null>(initialLoc);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const query = useQuery({
|
||||
@@ -61,20 +75,39 @@ export default function KitchenScreen() {
|
||||
},
|
||||
});
|
||||
|
||||
if (query.isLoading) return <LoadingView />;
|
||||
if (query.isError) return <ErrorView onRetry={() => void query.refetch()} />;
|
||||
const all = query.data?.items ?? [];
|
||||
|
||||
const locTypes = Array.from(new Set(all.map((i) => i.locationType)));
|
||||
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 = all
|
||||
.filter((i) => (locFilter ? i.locationType === locFilter : true))
|
||||
.filter((i) => (q ? i.displayName.toLowerCase().includes(q) : true))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.locationName.localeCompare(b.locationName, "sv") ||
|
||||
a.displayName.localeCompare(b.displayName, "sv"),
|
||||
);
|
||||
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) => {
|
||||
@@ -86,32 +119,23 @@ export default function KitchenScreen() {
|
||||
|
||||
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} st ur ${label}?`, [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{ text: "Ta bort", style: "destructive", onPress: () => bulkRemove.mutate(ids) },
|
||||
]);
|
||||
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>
|
||||
<TextInput
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder="Sök vara…"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
color: colors.text,
|
||||
backgroundColor: colors.surface,
|
||||
marginBottom: spacing.sm,
|
||||
}}
|
||||
/>
|
||||
<Input value={search} onChangeText={setSearch} placeholder="Sök vara…" autoCorrect={false} />
|
||||
|
||||
<Row style={{ flexWrap: "wrap", gap: spacing.xs }}>
|
||||
<Row>
|
||||
<Button
|
||||
label="Alla"
|
||||
variant={locFilter === null ? "primary" : "ghost"}
|
||||
@@ -127,13 +151,11 @@ export default function KitchenScreen() {
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
style={{ justifyContent: "space-between", alignItems: "center", marginVertical: spacing.sm }}
|
||||
>
|
||||
<Row style={{ justifyContent: "space-between" }}>
|
||||
<Small>
|
||||
{visible.length} varor{selected.size > 0 ? ` · ${selected.size} valda` : ""}
|
||||
</Small>
|
||||
<Row style={{ gap: spacing.xs }}>
|
||||
<Row style={{ justifyContent: "flex-end" }}>
|
||||
{selected.size > 0 && (
|
||||
<Button
|
||||
label={`Ta bort valda (${selected.size})`}
|
||||
@@ -141,37 +163,46 @@ export default function KitchenScreen() {
|
||||
onPress={() => confirmDelete([...selected], "markeringen")}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
label="Töm listan"
|
||||
variant="ghost"
|
||||
onPress={() =>
|
||||
confirmDelete(
|
||||
visible.map((i) => i.id),
|
||||
locFilter ? locLabel(locFilter) : "hela lagret",
|
||||
)
|
||||
}
|
||||
/>
|
||||
{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." />
|
||||
)}
|
||||
{visible.map((item) => {
|
||||
const isSel = selected.has(item.id);
|
||||
return (
|
||||
<Card key={item.id} onPress={() => toggle(item.id)}>
|
||||
<Row style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Body>
|
||||
{isSel ? "☑ " : "☐ "}
|
||||
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
|
||||
</Body>
|
||||
<Small>{locLabel(item.locationType)}</Small>
|
||||
</Row>
|
||||
{item.expiry.pastBestBefore && <Small>⚠︎ bäst före har passerat</Small>}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export const inventoryQuerySchema = z.object({
|
||||
storageLocationId: uuidSchema.optional(),
|
||||
expiryStatus: z.enum(["fresh", "use_soon", "expiring", "expired", "unknown"]).optional(),
|
||||
search: z.string().max(80).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(100),
|
||||
limit: z.coerce.number().int().min(1).max(500).default(100),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
});
|
||||
export type InventoryQuery = z.infer<typeof inventoryQuerySchema>;
|
||||
|
||||
Reference in New Issue
Block a user