0cc8f036d6
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.
243 lines
7.8 KiB
TypeScript
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>
|
|
);
|
|
}
|