Files
Cibello-app/apps/mobile/src/app/shopping.tsx
T
Claude 0cc8f036d6 fix(inkopslista): visa den genererade listan (inte en gammal tom)
POST aterananvander EN aktiv lista per veckoplan (staplar inte nya),
GET sorterar nyaste forst, och appen navigerar med listId. Tidigare
hamnade de genererade raderna i en lista skarmen inte visade -> tomt.
2026-08-19 23:33:32 +00:00

243 lines
7.8 KiB
TypeScript

import { useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import { api } from "@/lib/api";
import { t } from "@/lib/i18n";
import { formatMinor } from "@/lib/money";
import {
Body,
Button,
Card,
EmptyState,
ErrorView,
Heading,
Input,
LoadingView,
Row,
Screen,
Small,
Spacer,
} from "@/components/ui";
import { colors, spacing } from "@/lib/theme";
import { formatQuantity } from "@/lib/units";
/** Inköpslista (spec §27): delad, avdelningssorterad, uppdaterar lagret vid avslut. */
interface ShoppingItem {
id: string;
displayName: string;
quantity: number;
unit: string;
storeSection: string;
estimatedPriceMinor: number | null;
checked: boolean;
}
interface ListResponse {
list: { id: string; name: string };
items: ShoppingItem[];
estimatedTotalMinor: number;
currency: string;
}
/** Butikssektionernas etiketter bor i i18n-katalogen: shopping.section.<id> (12 språk). */
const SECTION_IDS = new Set([
"frukt_gront",
"brod",
"mejeri",
"kott_fagel",
"fisk",
"chark",
"frys",
"skafferi",
"konserver",
"kryddor_bak",
"dryck",
"snacks",
"hygien_ovrigt",
]);
const sectionLabel = (id: string): string =>
SECTION_IDS.has(id) ? t(`shopping.section.${id}`) : id;
export default function ShoppingScreen() {
const queryClient = useQueryClient();
const [newItem, setNewItem] = useState("");
const params = useLocalSearchParams<{ listId?: string }>();
const paramListId = typeof params.listId === "string" ? params.listId : undefined;
const lists = useQuery({
queryKey: ["shopping-lists"],
queryFn: async () => {
const result = await api<{ lists: Array<{ id: string }> }>("/v1/shopping-lists");
if (result.lists.length === 0) {
const created = await api<{ list: { id: string } }>("/v1/shopping-lists", {
method: "POST",
body: { name: t("shopping.title") },
});
return [created.list];
}
return result.lists;
},
});
// Prioritera listId från navigering (t.ex. nygenererad från veckoplan),
// annars nyaste aktiva listan.
const listId = paramListId ?? lists.data?.[0]?.id;
const list = useQuery({
queryKey: ["shopping-list", listId],
queryFn: () => api<ListResponse>(`/v1/shopping-lists/${listId}`),
enabled: Boolean(listId),
});
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ["shopping-list", listId] });
void queryClient.invalidateQueries({ queryKey: ["shopping-lists"] });
};
const addItem = useMutation({
mutationFn: (displayName: string) =>
api(`/v1/shopping-lists/${listId}/items`, { method: "POST", body: { displayName } }),
onSuccess: () => {
setNewItem("");
invalidate();
},
onError: (err) =>
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
});
const toggle = useMutation({
mutationFn: (item: ShoppingItem) =>
api(`/v1/shopping-lists/${listId}/items/${item.id}`, {
method: "PATCH",
body: { checked: !item.checked },
}),
onSuccess: invalidate,
onError: (err) =>
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
});
const remove = useMutation({
mutationFn: (itemId: string) =>
api(`/v1/shopping-lists/${listId}/items/${itemId}`, { method: "DELETE" }),
onSuccess: invalidate,
onError: (err) =>
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
});
const confirmRemove = (item: ShoppingItem) =>
Alert.alert(item.displayName, "Ta bort varan från listan?", [
{ text: t("common.cancel"), style: "cancel" },
{ text: "Ta bort", style: "destructive", onPress: () => remove.mutate(item.id) },
]);
const complete = useMutation({
mutationFn: () =>
api(`/v1/shopping-lists/${listId}/complete`, {
method: "POST",
body: { addToInventory: true },
}),
onSuccess: async (result) => {
const added = (result as { itemsAddedToInventory?: number }).itemsAddedToInventory ?? 0;
await queryClient.invalidateQueries({ queryKey: ["inventory"] });
await queryClient.invalidateQueries({ queryKey: ["what-to-eat"] });
invalidate();
Alert.alert(t("shopping.completedTitle"), t("shopping.completedBody", { count: added }));
},
onError: (err) =>
Alert.alert(t("common.oops"), err instanceof Error ? err.message : t("common.error")),
});
if (lists.isLoading || list.isLoading) return <LoadingView />;
if (list.isError || !list.data) return <ErrorView onRetry={() => void list.refetch()} />;
const grouped = new Map<string, ShoppingItem[]>();
for (const item of list.data.items) {
const arr = grouped.get(item.storeSection) ?? [];
arr.push(item);
grouped.set(item.storeSection, arr);
}
const checkedCount = list.data.items.filter((i) => i.checked).length;
return (
<Screen>
<Row>
<View style={{ flex: 1 }}>
<Input
placeholder={t("shopping.addPlaceholder")}
value={newItem}
onChangeText={setNewItem}
onSubmitEditing={() => newItem.trim() && addItem.mutate(newItem.trim())}
returnKeyType="done"
/>
</View>
<Button label="+" onPress={() => newItem.trim() && addItem.mutate(newItem.trim())} />
</Row>
{list.data.items.length === 0 && <EmptyState text={t("shopping.empty")} />}
{[...grouped.entries()].map(([section, items]) => (
<Card key={section}>
<Heading>{sectionLabel(section)}</Heading>
{items.map((item) => (
<Row
key={item.id}
style={{ justifyContent: "space-between", alignItems: "center", paddingVertical: 6 }}
>
<Pressable style={{ flex: 1 }} onPress={() => toggle.mutate(item)}>
<Body>
<Text style={{ color: item.checked ? colors.primary : colors.textMuted }}>
{item.checked ? "☑" : "☐"}
</Text>{" "}
<Text
style={
item.checked
? { textDecorationLine: "line-through", color: colors.textMuted }
: undefined
}
>
{item.displayName} · {formatQuantity(item.quantity, item.unit)}
</Text>
</Body>
</Pressable>
{item.estimatedPriceMinor != null && (
<Small>
{t("shopping.estimated", {
amount: formatMinor(item.estimatedPriceMinor, list.data.currency),
})}
</Small>
)}
{/* Radera en vara man ångrat (utan att köpa den). */}
<Pressable
onPress={() => confirmRemove(item)}
hitSlop={8}
style={{ paddingHorizontal: spacing.sm }}
>
<Text style={{ color: colors.textMuted, fontSize: 18 }}></Text>
</Pressable>
</Row>
))}
</Card>
))}
{list.data.estimatedTotalMinor > 0 && (
<Small>
{t("shopping.estimatedTotal", {
amount: formatMinor(list.data.estimatedTotalMinor, list.data.currency),
})}
</Small>
)}
<Spacer size={spacing.sm} />
{checkedCount > 0 && (
<>
<Button
label={`${t("shopping.complete")} (${checkedCount})`}
onPress={() => complete.mutate()}
loading={complete.isPending}
/>
<Small>{t("shopping.completeNote")}</Small>
</>
)}
</Screen>
);
}